commit 0368547137da549d15a9bfa6620eb6df9b205799 Author: chang Date: Thu Apr 17 01:08:15 2025 +0800 添加 OIDC 和 OAuth2 服务器的基础结构,包括配置、数据库模型、服务、处理器和路由。新增登录页面模板,支持用户认证和授权流程。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c32fa5f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/oidc_server.db diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..47775ff --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..58ab0b2 --- /dev/null +++ b/config/config.yaml @@ -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" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..29b328e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..764194a --- /dev/null +++ b/go.sum @@ -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= diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..338ea38 --- /dev/null +++ b/handlers/auth.go @@ -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()) +} diff --git a/handlers/oidc.go b/handlers/oidc.go new file mode 100644 index 0000000..f72817c --- /dev/null +++ b/handlers/oidc.go @@ -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"}) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..995b835 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..6eddd37 --- /dev/null +++ b/middleware/auth.go @@ -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() + } +} diff --git a/models/authorization_code.go b/models/authorization_code.go new file mode 100644 index 0000000..4ed7997 --- /dev/null +++ b/models/authorization_code.go @@ -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" +} diff --git a/models/client.go b/models/client.go new file mode 100644 index 0000000..abea721 --- /dev/null +++ b/models/client.go @@ -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" +} diff --git a/models/migration.go b/models/migration.go new file mode 100644 index 0000000..45d4366 --- /dev/null +++ b/models/migration.go @@ -0,0 +1,14 @@ +package models + +import ( + "gorm.io/gorm" +) + +// AutoMigrate 自动迁移数据库表结构 +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &User{}, + &Client{}, + &AuthorizationCode{}, + ) +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..4725fbe --- /dev/null +++ b/models/user.go @@ -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" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2829dc4 --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# OIDC-OATH-SERVER + +一个标准的 OIDC 和 OAUTH2 服务端,提供简单的账号密码登录 \ No newline at end of file diff --git a/services/auth.go b/services/auth.go new file mode 100644 index 0000000..c408830 --- /dev/null +++ b/services/auth.go @@ -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 +} diff --git a/services/oauth.go b/services/oauth.go new file mode 100644 index 0000000..617abfa --- /dev/null +++ b/services/oauth.go @@ -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) +} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d1c483b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,87 @@ + + + + 登录 - OIDC 服务 + + + + + +
+

登录

+ {{if .Error}} +
{{.Error}}
+ {{end}} +
+ + + + + + +
+ + +
+ +
+ + +
+ + +
+
+ + \ No newline at end of file