diff --git a/VERSION b/VERSION index cb174d5..2aa40f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.1 \ No newline at end of file +1.2.2-dev \ No newline at end of file diff --git a/cmd/woj/woj.go b/cmd/woj/woj.go index c4d7100..385c117 100644 --- a/cmd/woj/woj.go +++ b/cmd/woj/woj.go @@ -17,6 +17,7 @@ import ( "git.0x7f.app/WOJ/woj-server/internal/service/user" "git.0x7f.app/WOJ/woj-server/internal/web/jwt" "git.0x7f.app/WOJ/woj-server/internal/web/metrics" + "git.0x7f.app/WOJ/woj-server/internal/web/oauth" "git.0x7f.app/WOJ/woj-server/internal/web/router" "github.com/getsentry/sentry-go" "github.com/samber/do" @@ -75,6 +76,7 @@ func prepareServices(c *cli.Context) *do.Injector { { // web helper services do.Provide(injector, metrics.NewService) do.Provide(injector, jwt.NewService) + do.Provide(injector, oauth.NewService) do.Provide(injector, router.NewService) } diff --git a/config.docker.yaml b/config.docker.yaml index 2eecdd0..73ca4bc 100644 --- a/config.docker.yaml +++ b/config.docker.yaml @@ -1,8 +1,12 @@ WebServer: Address: ${WEB_SERVER_ADDRESS} Port: ${WEB_SERVER_PORT} + PublicBase: ${WEB_SERVER_PUBLIC_BASE} JwtSigningKey: ${WEB_SERVER_JWT_SIGNING_KEY} JwtExpireHour: ${WEB_SERVER_JWT_EXPIRE_HOUR} + OAuthDomain: ${WEB_SERVER_OAUTH_DOMAIN} + OAuthClientID: ${WEB_SERVER_OAUTH_CLIENT_ID} + OAuthClientSecret: ${WEB_SERVER_OAUTH_CLIENT_SECRET} Redis: Db: ${REDIS_DB} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 870c55d..1062688 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -36,8 +36,12 @@ function check_env() { check_env "WEB_SERVER_ADDRESS" "0.0.0.0" true check_env "WEB_SERVER_PORT" 8000 false +check_env "WEB_SERVER_PUBLIC_BASE" "http://127.0.0.1:8000" true check_env "WEB_SERVER_JWT_SIGNING_KEY" "$(head -n 10 /dev/urandom | md5sum | cut -c 1-32)" true check_env "WEB_SERVER_JWT_EXPIRE_HOUR" 12 false +check_env "WEB_SERVER_OAUTH_DOMAIN" "" true +check_env "WEB_SERVER_OAUTH_CLIENT_ID" "" true +check_env "WEB_SERVER_OAUTH_CLIENT_SECRET" "" true check_env "REDIS_DB" 0 false check_env "REDIS_QUEUE_DB" 1 false diff --git a/go.mod b/go.mod index 288e5d0..f9aa1fe 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/TheZeroSlave/zapsentry v1.20.0 + github.com/coreos/go-oidc/v3 v3.9.0 github.com/getsentry/sentry-go v0.25.0 github.com/gin-contrib/cors v1.5.0 github.com/gin-contrib/pprof v1.4.0 @@ -23,6 +24,7 @@ require ( github.com/urfave/cli/v2 v2.26.0 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.17.0 + golang.org/x/oauth2 v0.13.0 golang.org/x/text v0.14.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.5.4 @@ -42,6 +44,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.1 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.3 // indirect github.com/go-openapi/spec v0.20.12 // indirect @@ -92,6 +95,7 @@ require ( golang.org/x/sys v0.15.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.1 // indirect + google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 7bc814e..3b040c0 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLI github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= +github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= @@ -58,6 +60,8 @@ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR 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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= +github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= @@ -90,6 +94,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -324,6 +329,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= @@ -350,6 +356,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -389,6 +397,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= @@ -414,6 +423,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/internal/api/user/login.go b/internal/api/user/login.go index 7e5998b..e9b515e 100644 --- a/internal/api/user/login.go +++ b/internal/api/user/login.go @@ -12,7 +12,7 @@ type loginRequest struct { Password string `form:"password" json:"password" binding:"required"` } -type loginResponse struct { +type LoginResponse struct { Token string `json:"token"` NickName string `json:"nickname"` } @@ -25,7 +25,7 @@ type loginResponse struct { // @Produce json // @Param username formData string true "username" // @Param password formData string true "password" -// @Response 200 {object} e.Response[loginResponse] "jwt token and user's nickname" +// @Response 200 {object} e.Response[LoginResponse] "jwt token and user's nickname" // @Router /v1/user/login [post] func (h *handler) Login(c *gin.Context) { req := new(loginRequest) @@ -57,5 +57,5 @@ func (h *handler) Login(c *gin.Context) { Version: version, } token, status := h.jwtService.SignClaim(claim) - e.Pong(c, status, loginResponse{Token: token, NickName: u.NickName}) + e.Pong(c, status, LoginResponse{Token: token, NickName: u.NickName}) } diff --git a/internal/e/code.go b/internal/e/code.go index df0f552..9fa3e4e 100644 --- a/internal/e/code.go +++ b/internal/e/code.go @@ -21,6 +21,10 @@ const ( TokenInvalid TokenSignError TokenRevoked + OAuthStateMismatch + OAuthExchangeFailed + OAuthVerifyFailed + OAuthGetClaimsFailed ) const ( @@ -30,6 +34,8 @@ const ( UserUnauthenticated UserUnauthorized UserDisabled + UserWithoutPassword + UserInvalid ) const ( @@ -74,13 +80,17 @@ var msgText = map[Status]string{ DatabaseError: "Database Error", RedisError: "Redis Error", - TokenUnknown: "Unknown Error (Token)", - TokenEmpty: "Token Empty", - TokenMalformed: "Token Malformed", - TokenTimeError: "Token Time Error", - TokenInvalid: "Token Invalid", - TokenSignError: "Token Sign Error", - TokenRevoked: "Token Revoked", + TokenUnknown: "Unknown Error (Token)", + TokenEmpty: "Token Empty", + TokenMalformed: "Token Malformed", + TokenTimeError: "Token Time Error", + TokenInvalid: "Token Invalid", + TokenSignError: "Token Sign Error", + TokenRevoked: "Token Revoked", + OAuthStateMismatch: "OAuth State Mismatch", + OAuthExchangeFailed: "OAuth Exchange Failed", + OAuthVerifyFailed: "OAuth Verify Failed", + OAuthGetClaimsFailed: "OAuth Get Claims Failed", UserNotFound: "User Not Found", UserWrongPassword: "User Wrong Password", @@ -88,6 +98,8 @@ var msgText = map[Status]string{ UserUnauthenticated: "User Unauthenticated", UserUnauthorized: "User Unauthorized", UserDisabled: "User Disabled", + UserWithoutPassword: "User Without Password", + UserInvalid: "User Invalid", ProblemNotFound: "Problem Not Found", ProblemNotAvailable: "Problem Not Available", diff --git a/internal/model/User.go b/internal/model/User.go index cc6507a..532d149 100644 --- a/internal/model/User.go +++ b/internal/model/User.go @@ -9,6 +9,6 @@ type User struct { UserName string `json:"user_name" gorm:"not null;uniqueIndex"` NickName string `json:"nick_name" gorm:"not null"` Role Role `json:"role" gorm:"not null"` - Password []byte `json:"-" gorm:"not null"` + Password []byte `json:"-"` IsEnabled bool `json:"is_enabled" gorm:"not null;index"` } diff --git a/internal/model/config.go b/internal/model/config.go index ddf1393..2bc574a 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -1,10 +1,14 @@ package model type ConfigWebServer struct { - Address string `yaml:"Address"` - Port int `yaml:"Port"` - JwtSigningKey string `yaml:"JwtSigningKey"` - JwtExpireHour int `yaml:"JwtExpireHour"` + Address string `yaml:"Address"` + Port int `yaml:"Port"` + PublicBase string `yaml:"PublicBase"` + JwtSigningKey string `yaml:"JwtSigningKey"` + JwtExpireHour int `yaml:"JwtExpireHour"` + OAuthDomain string `yaml:"OAuthDomain"` + OAuthClientID string `yaml:"OAuthClientID"` + OAuthClientSecret string `yaml:"OAuthClientSecret"` } type ConfigRedis struct { diff --git a/internal/service/user/login.go b/internal/service/user/login.go index f64c4d9..7f99841 100644 --- a/internal/service/user/login.go +++ b/internal/service/user/login.go @@ -29,6 +29,10 @@ func (s *service) Login(data *LoginData) (*model.User, e.Status) { if !user.IsEnabled { return nil, e.UserDisabled } + if len(user.Password) == 0 { + // created by oauth + return nil, e.UserWithoutPassword + } err = bcrypt.CompareHashAndPassword(user.Password, []byte(data.Password)) if err != nil { diff --git a/internal/web/oauth/callback.go b/internal/web/oauth/callback.go new file mode 100644 index 0000000..3c217b2 --- /dev/null +++ b/internal/web/oauth/callback.go @@ -0,0 +1,103 @@ +package oauth + +import ( + "context" + userApi "git.0x7f.app/WOJ/woj-server/internal/api/user" + "git.0x7f.app/WOJ/woj-server/internal/e" + "git.0x7f.app/WOJ/woj-server/internal/model" + "git.0x7f.app/WOJ/woj-server/internal/service/user" + "git.0x7f.app/WOJ/woj-server/pkg/utils" + "github.com/gin-gonic/gin" +) + +// CallbackHandler +// @Summary Callback with OAuth2 +// @Description Callback endpoint from OAuth2 +// @Tags oauth +// @Produce json +// @Router /oauth/callback [get] +func (s *service) CallbackHandler() gin.HandlerFunc { + // TODO: we are returning e.Response directly here, we should redirect to a trampoline page, passing the response as query string + + return func(c *gin.Context) { + // verify state + signed, err := c.Cookie(oauthStateCookieName) + if err != nil { + e.Pong[any](c, e.InvalidParameter, nil) + return + } + + state := c.Query("state") + if !utils.SignAndCompare(state, signed, []byte(s.conf.ClientSecret)) { + e.Pong[any](c, e.OAuthStateMismatch, nil) + return + } + + // Exchange code for token + token, err := s.conf.Exchange(context.Background(), c.Query("code")) + if err != nil { + e.Pong[any](c, e.OAuthExchangeFailed, nil) + return + } + + // Extract the ID Token from OAuth2 token. + raw, ok := token.Extra("id_token").(string) + if !ok { + e.Pong[any](c, e.OAuthExchangeFailed, nil) + return + } + + // Parse and verify ID Token payload. + idToken, err := s.verifier.Verify(context.Background(), raw) + if err != nil { + e.Pong[any](c, e.OAuthVerifyFailed, nil) + return + } + + // Extract custom claims + // TODO: extract role from claims + // TODO: currently username = email, add Email in User model + var claims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Nickname string `json:"preferred_username"` + Role string `json:"role"` + } + if err := idToken.Claims(&claims); err != nil { + e.Pong[any](c, e.OAuthGetClaimsFailed, nil) + return + } + if !claims.EmailVerified || claims.Email == "" || claims.Nickname == "" { + e.Pong[any](c, e.UserInvalid, nil) + return + } + + // Check User Existence + u, status := s.user.ProfileOrCreate(&user.CreateData{UserName: claims.Email, NickName: claims.Nickname}) + if status != e.Success { + e.Pong[any](c, status, nil) + return + } + + // Increment User Version + version, status := s.user.IncrVersion(u.ID) + if status != e.Success { + e.Pong[any](c, status, nil) + return + } + + // Sign JWT Token + claim := &model.Claim{ + UID: u.ID, + Role: u.Role, + Version: version, + } + jwt, status := s.jwt.SignClaim(claim) + if status != e.Success { + e.Pong[any](c, status, nil) + return + } + + e.Pong(c, status, userApi.LoginResponse{Token: jwt, NickName: u.NickName}) + } +} diff --git a/internal/web/oauth/login.go b/internal/web/oauth/login.go new file mode 100644 index 0000000..44bfeed --- /dev/null +++ b/internal/web/oauth/login.go @@ -0,0 +1,32 @@ +package oauth + +import ( + "git.0x7f.app/WOJ/woj-server/internal/e" + "git.0x7f.app/WOJ/woj-server/pkg/utils" + "github.com/gin-gonic/gin" + "net/http" +) + +const ( + oauthStateCookieName = "oauth_state" +) + +// LoginHandler +// @Summary Login with OAuth2 +// @Description Get OAuth2 Login URL +// @Tags oauth +// @Produce json +// @Response 200 {object} e.Response[string] "random string" +// @Router /oauth/login [post] +func (s *service) LoginHandler() gin.HandlerFunc { + return func(c *gin.Context) { + state := utils.RandomString(64) + signed := utils.SignString(state, []byte(s.conf.ClientSecret)) + url := s.conf.AuthCodeURL(state) + + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(oauthStateCookieName, signed, 15*60, "/", "", false, true) + + e.Pong(c, e.Success, url) + } +} diff --git a/internal/web/oauth/service.go b/internal/web/oauth/service.go new file mode 100644 index 0000000..b83f142 --- /dev/null +++ b/internal/web/oauth/service.go @@ -0,0 +1,92 @@ +package oauth + +import ( + "context" + "git.0x7f.app/WOJ/woj-server/internal/misc/config" + "git.0x7f.app/WOJ/woj-server/internal/misc/log" + "git.0x7f.app/WOJ/woj-server/internal/service/user" + "git.0x7f.app/WOJ/woj-server/internal/web/jwt" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" + "github.com/samber/do" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +type Service interface { + LoginHandler() gin.HandlerFunc + CallbackHandler() gin.HandlerFunc + + IsEnabled() bool + GetLoginPath() string + GetCallbackPath() string + HealthCheck() error +} + +const ( + basePath = "/oauth" + callbackPath = basePath + "/callback" + loginPath = basePath + "/login" +) + +func NewService(i *do.Injector) (Service, error) { + srv := &service{} + srv.log = do.MustInvoke[log.Service](i).GetLogger("oauth") + srv.jwt = do.MustInvoke[jwt.Service](i) + srv.user = do.MustInvoke[user.Service](i) + srv.enabled = false + + conf := do.MustInvoke[config.Service](i).GetConfig() + + if conf.WebServer.OAuthDomain == "" { + return srv, srv.err + } + + srv.provider, srv.err = oidc.NewProvider(context.Background(), conf.WebServer.OAuthDomain) + if srv.err != nil { + srv.log.Error("failed to create oauth provider", zap.Error(srv.err), zap.String("domain", conf.WebServer.OAuthDomain)) + return srv, srv.err + } + + srv.verifier = srv.provider.Verifier(&oidc.Config{ClientID: conf.WebServer.OAuthClientID}) + + srv.conf = oauth2.Config{ + ClientID: conf.WebServer.OAuthClientID, + ClientSecret: conf.WebServer.OAuthClientSecret, + RedirectURL: conf.WebServer.PublicBase + callbackPath, + Endpoint: srv.provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email", "roles"}, + } + + srv.enabled = true + return srv, srv.err +} + +type service struct { + log *zap.Logger + jwt jwt.Service + user user.Service + + provider *oidc.Provider + conf oauth2.Config + verifier *oidc.IDTokenVerifier + + enabled bool + err error +} + +func (s *service) IsEnabled() bool { + return s.enabled && s.err == nil +} + +func (s *service) GetLoginPath() string { + return loginPath +} + +func (s *service) GetCallbackPath() string { + return callbackPath +} + +func (s *service) HealthCheck() error { + return s.err +} diff --git a/internal/web/router/router.go b/internal/web/router/router.go index 146f92c..db3825e 100644 --- a/internal/web/router/router.go +++ b/internal/web/router/router.go @@ -5,6 +5,7 @@ import ( "git.0x7f.app/WOJ/woj-server/internal/misc/log" "git.0x7f.app/WOJ/woj-server/internal/model" "git.0x7f.app/WOJ/woj-server/internal/web/metrics" + "git.0x7f.app/WOJ/woj-server/internal/web/oauth" _ "git.0x7f.app/WOJ/woj-server/internal/web/router/docs" "git.0x7f.app/WOJ/woj-server/pkg/utils" sentrygin "github.com/getsentry/sentry-go/gin" @@ -31,6 +32,7 @@ type Service interface { func NewService(i *do.Injector) (Service, error) { srv := &service{} srv.metric = do.MustInvoke[metrics.Service](i) + srv.oauth = do.MustInvoke[oauth.Service](i) srv.logger = do.MustInvoke[log.Service](i) conf := do.MustInvoke[config.Service](i).GetConfig() @@ -40,10 +42,14 @@ func NewService(i *do.Injector) (Service, error) { } type service struct { - metric metrics.Service logger log.Service engine *gin.Engine - err error + + // middlewares + metric metrics.Service + oauth oauth.Service + + err error } func (s *service) GetRouter() *gin.Engine { @@ -129,6 +135,12 @@ func (s *service) initRouters(conf *model.Config, injector *do.Injector) *gin.En api := r.Group("/api/") s.setupApi(api, injector) + // oauth2 + if s.oauth.IsEnabled() { + r.POST(s.oauth.GetLoginPath(), s.oauth.LoginHandler()) + r.GET(s.oauth.GetCallbackPath(), s.oauth.CallbackHandler()) + } + // fallback to frontend r.NoRoute(func(c *gin.Context) { c.File("./resource/frontend/index.html") diff --git a/pkg/utils/string.go b/pkg/utils/string.go index be02105..669c536 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,6 +1,9 @@ package utils import ( + "crypto/hmac" + "crypto/sha512" + "encoding/base64" "math/rand" ) @@ -14,3 +17,22 @@ func RandomString(n int) string { return string(s) } + +func SignString(s string, key []byte) string { + mac := hmac.New(sha512.New, key) + mac.Write([]byte(s)) + + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func SignAndCompare(s string, exp string, key []byte) bool { + mac := hmac.New(sha512.New, key) + mac.Write([]byte(s)) + + decoded, err := base64.StdEncoding.DecodeString(exp) + if err != nil { + return false + } + + return hmac.Equal(mac.Sum(nil), decoded) +}