添加 OIDC 和 OAuth2 服务器的基础结构,包括配置、数据库模型、服务、处理器和路由。新增登录页面模板,支持用户认证和授权流程。

This commit is contained in:
2025-04-17 01:08:15 +08:00
commit 0368547137
17 changed files with 1049 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data/oidc_server.db

46
config/config.go Normal file
View File

@@ -0,0 +1,46 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
} `yaml:"server"`
Database struct {
Type string `yaml:"type"`
Path string `yaml:"path"`
} `yaml:"database"`
OAuth struct {
IssuerURL string `yaml:"issuer_url"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
RedirectURL string `yaml:"redirect_url"`
} `yaml:"oauth"`
JWT struct {
SigningKey string `yaml:"signing_key"`
} `yaml:"jwt"`
}
var GlobalConfig Config
func Init() error {
configFile, err := os.ReadFile("config/config.yaml")
if err != nil {
return err
}
err = yaml.Unmarshal(configFile, &GlobalConfig)
if err != nil {
return err
}
return nil
}

16
config/config.yaml Normal file
View File

@@ -0,0 +1,16 @@
server:
port: 8080
host: "localhost"
database:
type: "sqlite"
path: "data/oidc_server.db"
oauth:
issuer_url: "http://localhost:8080"
client_id: "default_client"
client_secret: "default_secret"
redirect_url: "http://localhost:8080/callback"
jwt:
signing_key: "your-secret-key-here"

49
go.mod Normal file
View File

@@ -0,0 +1,49 @@
module oidc-oauth2-server
go 1.21
require (
github.com/gin-contrib/sessions v0.0.5
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v4 v4.5.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde
)
require (
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

112
go.sum Normal file
View File

@@ -0,0 +1,112 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde h1:9DShaph9qhkIYw7QF91I/ynrr4cOO2PZra2PFD7Mfeg=
gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

81
handlers/auth.go Normal file
View File

@@ -0,0 +1,81 @@
package handlers
import (
"net/http"
"oidc-oauth2-server/services"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *services.AuthService
}
type LoginData struct {
Error string
RedirectURI string
State string
ClientID string
ResponseType string
Scope string
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// ShowLogin 显示登录页面
func (h *AuthHandler) ShowLogin(c *gin.Context) {
data := LoginData{
RedirectURI: c.Query("redirect_uri"),
State: c.Query("state"),
ClientID: c.Query("client_id"),
ResponseType: c.Query("response_type"),
Scope: c.Query("scope"),
}
c.HTML(http.StatusOK, "login.html", data)
}
// HandleLogin 处理登录请求
func (h *AuthHandler) HandleLogin(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
user, err := h.authService.Authenticate(username, password)
if err != nil {
data := LoginData{
Error: "用户名或密码错误",
RedirectURI: c.PostForm("redirect_uri"),
State: c.PostForm("state"),
ClientID: c.PostForm("client_id"),
ResponseType: c.PostForm("response_type"),
Scope: c.PostForm("scope"),
}
c.HTML(http.StatusOK, "login.html", data)
return
}
// 设置用户会话
session := sessions.Default(c)
session.Set("user_id", user.ID)
session.Save()
// 重定向回授权页面
redirectURI := c.PostForm("redirect_uri")
if redirectURI == "" {
redirectURI = "/authorize"
}
query := c.Request.URL.Query()
query.Set("client_id", c.PostForm("client_id"))
query.Set("response_type", c.PostForm("response_type"))
query.Set("scope", c.PostForm("scope"))
query.Set("state", c.PostForm("state"))
query.Set("redirect_uri", redirectURI)
c.Redirect(http.StatusFound, "/authorize?"+query.Encode())
}

171
handlers/oidc.go Normal file
View File

@@ -0,0 +1,171 @@
package handlers
import (
"crypto/rsa"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"oidc-oauth2-server/models"
"oidc-oauth2-server/services"
)
type OIDCHandler struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
config *OIDCConfig
oauthService *services.OAuthService
authService *services.AuthService
}
type OIDCConfig struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserinfoEndpoint string `json:"userinfo_endpoint"`
JwksURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
ScopesSupported []string `json:"scopes_supported"`
ClaimsSupported []string `json:"claims_supported"`
}
func NewOIDCHandler(issuerURL string, oauthService *services.OAuthService, authService *services.AuthService) *OIDCHandler {
config := &OIDCConfig{
Issuer: issuerURL,
AuthorizationEndpoint: issuerURL + "/authorize",
TokenEndpoint: issuerURL + "/token",
UserinfoEndpoint: issuerURL + "/userinfo",
JwksURI: issuerURL + "/jwks",
ResponseTypesSupported: []string{"code"},
SubjectTypesSupported: []string{"public"},
ScopesSupported: []string{"openid", "profile", "email"},
ClaimsSupported: []string{"sub", "name", "email", "email_verified"},
}
return &OIDCHandler{
config: config,
oauthService: oauthService,
authService: authService,
}
}
// OpenIDConfiguration handles /.well-known/openid-configuration endpoint
func (h *OIDCHandler) OpenIDConfiguration(c *gin.Context) {
c.JSON(http.StatusOK, h.config)
}
// Authorize handles /authorize endpoint
func (h *OIDCHandler) Authorize(c *gin.Context) {
// 检查用户是否已登录
session := sessions.Default(c)
userID := session.Get("user_id")
if userID == nil {
// 用户未登录,重定向到登录页面
query := c.Request.URL.Query()
c.Redirect(http.StatusFound, "/login?"+query.Encode())
return
}
req := &services.AuthorizeRequest{
ResponseType: c.Query("response_type"),
ClientID: c.Query("client_id"),
RedirectURI: c.Query("redirect_uri"),
Scope: c.Query("scope"),
State: c.Query("state"),
}
if err := h.oauthService.ValidateAuthorizeRequest(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
authCode, err := h.oauthService.GenerateAuthorizationCode(userID.(uint), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization code"})
return
}
// 构建重定向 URL
redirectURL, _ := url.Parse(req.RedirectURI)
q := redirectURL.Query()
q.Set("code", authCode.Code)
if req.State != "" {
q.Set("state", req.State)
}
redirectURL.RawQuery = q.Encode()
c.Redirect(http.StatusFound, redirectURL.String())
}
// Token handles /token endpoint
func (h *OIDCHandler) Token(c *gin.Context) {
req := &services.TokenRequest{
GrantType: c.PostForm("grant_type"),
Code: c.PostForm("code"),
RedirectURI: c.PostForm("redirect_uri"),
ClientID: c.PostForm("client_id"),
ClientSecret: c.PostForm("client_secret"),
}
if req.GrantType != "authorization_code" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_grant_type"})
return
}
tokenResponse, err := h.oauthService.ExchangeToken(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// Userinfo handles /userinfo endpoint
func (h *OIDCHandler) Userinfo(c *gin.Context) {
// 从 token 中获取用户 ID
userID := c.GetUint("user_id")
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
// 获取用户信息
var user models.User
if err := h.authService.GetUserByID(userID, &user); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user_not_found"})
return
}
// 获取授权范围
scope := c.GetString("scope")
scopes := strings.Split(scope, " ")
// 准备返回的声明
claims := gin.H{
"sub": fmt.Sprintf("%d", user.ID),
}
// 根据授权范围添加相应的声明
for _, s := range scopes {
switch s {
case "profile":
claims["name"] = user.Username
case "email":
claims["email"] = user.Email
}
}
c.JSON(http.StatusOK, claims)
}
// JWKS handles /jwks endpoint
func (h *OIDCHandler) JWKS(c *gin.Context) {
// TODO: 实现 JWKS 密钥集获取逻辑
c.JSON(http.StatusNotImplemented, gin.H{"message": "Not implemented yet"})
}

78
main.go Normal file
View File

@@ -0,0 +1,78 @@
package main
import (
"fmt"
"log"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"oidc-oauth2-server/config"
"oidc-oauth2-server/handlers"
"oidc-oauth2-server/models"
"oidc-oauth2-server/services"
)
func main() {
// 初始化配置
if err := config.Init(); err != nil {
log.Fatalf("Failed to initialize config: %v", err)
}
// 初始化数据库连接
db, err := gorm.Open(sqlite.Open(config.GlobalConfig.Database.Path), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// 运行数据库迁移
if err := models.AutoMigrate(db); err != nil {
log.Fatalf("Failed to run database migrations: %v", err)
}
// 初始化服务
authService := services.NewAuthService(db)
oauthService := services.NewOAuthService(db, []byte(config.GlobalConfig.JWT.SigningKey))
// 设置 Gin 路由
r := gin.Default()
// 设置模板目录
r.LoadHTMLGlob("templates/*")
// 设置 session 中间件
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("oidc_session", store))
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
})
})
// 创建处理器
authHandler := handlers.NewAuthHandler(authService)
oidcHandler := handlers.NewOIDCHandler(config.GlobalConfig.OAuth.IssuerURL, oauthService, authService)
// 认证路由
r.GET("/login", authHandler.ShowLogin)
r.POST("/login", authHandler.HandleLogin)
// OIDC 端点
r.GET("/.well-known/openid-configuration", oidcHandler.OpenIDConfiguration)
r.GET("/authorize", oidcHandler.Authorize)
r.POST("/token", oidcHandler.Token)
r.GET("/userinfo", oidcHandler.Userinfo)
r.GET("/jwks", oidcHandler.JWKS)
// 启动服务器
addr := fmt.Sprintf("%s:%d", config.GlobalConfig.Server.Host, config.GlobalConfig.Server.Port)
log.Printf("Starting server on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

59
middleware/auth.go Normal file
View File

@@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v4"
)
func BearerAuth(jwtSecret []byte) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
// 检查 Bearer 前缀
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
c.Abort()
return
}
tokenString := parts[1]
// 解析和验证令牌
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 验证签名算法
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return jwtSecret, nil
})
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
if !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
// 将令牌中的声明存储在上下文中
if claims, ok := token.Claims.(jwt.MapClaims); ok {
c.Set("user_id", uint(claims["sub"].(float64)))
c.Set("scope", claims["scope"].(string))
}
c.Next()
}
}

View File

@@ -0,0 +1,22 @@
package models
import (
"time"
"gorm.io/gorm"
)
type AuthorizationCode struct {
gorm.Model
Code string `gorm:"uniqueIndex;not null"`
ClientID string `gorm:"not null"`
UserID uint `gorm:"not null"`
RedirectURI string `gorm:"not null"`
Scope string `gorm:"not null"`
ExpiresAt time.Time `gorm:"not null"`
Used bool `gorm:"default:false"`
}
func (ac *AuthorizationCode) TableName() string {
return "oauth_authorization_codes"
}

23
models/client.go Normal file
View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"gorm.io/gorm"
)
type Client struct {
gorm.Model
ClientID string `gorm:"uniqueIndex;not null"`
ClientSecret string `gorm:"not null"`
RedirectURIs []string `gorm:"type:json"`
GrantTypes []string `gorm:"type:json"`
Scopes []string `gorm:"type:json"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (c *Client) TableName() string {
return "oauth_clients"
}

14
models/migration.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import (
"gorm.io/gorm"
)
// AutoMigrate 自动迁移数据库表结构
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Client{},
&AuthorizationCode{},
)
}

22
models/user.go Normal file
View File

@@ -0,0 +1,22 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;not null"`
Password string `gorm:"not null"`
Email string `gorm:"uniqueIndex"`
LastLogin time.Time
IsActive bool `gorm:"default:true"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (u *User) TableName() string {
return "users"
}

3
readme.md Normal file
View File

@@ -0,0 +1,3 @@
# OIDC-OATH-SERVER
一个标准的 OIDC 和 OAUTH2 服务端,提供简单的账号密码登录

79
services/auth.go Normal file
View File

@@ -0,0 +1,79 @@
package services
import (
"errors"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"oidc-oauth2-server/models"
)
type AuthService struct {
db *gorm.DB
}
func NewAuthService(db *gorm.DB) *AuthService {
return &AuthService{db: db}
}
func (s *AuthService) Authenticate(username, password string) (*models.User, error) {
user := &models.User{}
if err := s.db.Where("username = ?", username).First(user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid username or password")
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, errors.New("invalid username or password")
}
// 更新最后登录时间
user.LastLogin = time.Now()
s.db.Save(user)
return user, nil
}
func (s *AuthService) CreateUser(username, password, email string) (*models.User, error) {
// 检查用户名是否已存在
var count int64
s.db.Model(&models.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return nil, errors.New("username already exists")
}
// 加密密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &models.User{
Username: username,
Password: string(hashedPassword),
Email: email,
IsActive: true,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// GetUserByID 根据用户 ID 获取用户信息
func (s *AuthService) GetUserByID(id uint, user *models.User) error {
result := s.db.First(user, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return errors.New("user not found")
}
return result.Error
}
return nil
}

186
services/oauth.go Normal file
View File

@@ -0,0 +1,186 @@
package services
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/golang-jwt/jwt/v4"
"gorm.io/gorm"
"oidc-oauth2-server/models"
)
type OAuthService struct {
db *gorm.DB
jwtSecret []byte
tokenTTL time.Duration
}
type AuthorizeRequest struct {
ResponseType string
ClientID string
RedirectURI string
Scope string
State string
}
type TokenRequest struct {
GrantType string
Code string
RedirectURI string
ClientID string
ClientSecret string
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
func NewOAuthService(db *gorm.DB, jwtSecret []byte) *OAuthService {
return &OAuthService{
db: db,
jwtSecret: jwtSecret,
tokenTTL: time.Hour,
}
}
func (s *OAuthService) ValidateAuthorizeRequest(req *AuthorizeRequest) error {
if req.ResponseType != "code" {
return errors.New("unsupported response type")
}
client := &models.Client{}
if err := s.db.First(client, "client_id = ?", req.ClientID).Error; err != nil {
return errors.New("invalid client")
}
// 验证重定向 URI
validRedirect := false
for _, uri := range client.RedirectURIs {
if uri == req.RedirectURI {
validRedirect = true
break
}
}
if !validRedirect {
return errors.New("invalid redirect URI")
}
return nil
}
func (s *OAuthService) GenerateAuthorizationCode(userID uint, req *AuthorizeRequest) (*models.AuthorizationCode, error) {
// 生成随机授权码
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
code := base64.RawURLEncoding.EncodeToString(b)
authCode := &models.AuthorizationCode{
Code: code,
ClientID: req.ClientID,
RedirectURI: req.RedirectURI,
UserID: userID,
Scope: req.Scope,
ExpiresAt: time.Now().Add(10 * time.Minute),
Used: false,
}
// 保存授权码到数据库
if err := s.db.Create(authCode).Error; err != nil {
return nil, err
}
return authCode, nil
}
func (s *OAuthService) ExchangeToken(req *TokenRequest) (*TokenResponse, error) {
// 验证授权码
authCode := &models.AuthorizationCode{}
if err := s.db.Where("code = ? AND client_id = ? AND used = ?",
req.Code, req.ClientID, false).First(authCode).Error; err != nil {
return nil, errors.New("invalid authorization code")
}
// 验证授权码是否过期
if time.Now().After(authCode.ExpiresAt) {
return nil, errors.New("authorization code expired")
}
// 验证重定向 URI
if authCode.RedirectURI != req.RedirectURI {
return nil, errors.New("redirect URI mismatch")
}
// 验证客户端
client := &models.Client{}
if err := s.db.Where("client_id = ? AND client_secret = ?",
req.ClientID, req.ClientSecret).First(client).Error; err != nil {
return nil, errors.New("invalid client credentials")
}
// 获取用户信息
user := &models.User{}
if err := s.db.First(user, authCode.UserID).Error; err != nil {
return nil, errors.New("user not found")
}
// 生成访问令牌
accessToken, err := s.generateAccessToken(user, client, authCode.Scope)
if err != nil {
return nil, err
}
// 生成 ID 令牌
idToken, err := s.generateIDToken(user, client, authCode.Scope)
if err != nil {
return nil, err
}
// 标记授权码为已使用
authCode.Used = true
s.db.Save(authCode)
return &TokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int(s.tokenTTL.Seconds()),
IDToken: idToken,
}, nil
}
func (s *OAuthService) generateAccessToken(user *models.User, client *models.Client, scope string) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"sub": user.ID,
"exp": now.Add(s.tokenTTL).Unix(),
"iat": now.Unix(),
"iss": client.ClientID,
"scope": scope,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *OAuthService) generateIDToken(user *models.User, client *models.Client, scope string) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
"sub": user.ID,
"exp": now.Add(s.tokenTTL).Unix(),
"iat": now.Unix(),
"iss": client.ClientID,
"email": user.Email,
"email_verified": true,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}

87
templates/login.html Normal file
View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html>
<head>
<title>登录 - OIDC 服务</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: #333;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
width: 100%;
}
button:hover {
background-color: #0056b3;
}
.error {
color: #dc3545;
margin-bottom: 15px;
}
</style>
</head>
<body>
<div class="login-container">
<h2 style="text-align: center; margin-bottom: 30px;">登录</h2>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="client_id" value="{{.ClientID}}">
<input type="hidden" name="response_type" value="{{.ResponseType}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>