diff --git a/config.yaml b/config.yaml index 866d600..8663f6a 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,8 @@ WebServer: Address: 0.0.0.0 Port: 8000 + JwtSigningKey: 'rq67SdQIRABhHq40' + JwtExpireHour: 12 Redis: Db: 0 diff --git a/go.mod b/go.mod index 97ec06c..645f379 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-contrib/pprof v1.4.0 github.com/gin-contrib/zap v0.0.2 github.com/gin-gonic/gin v1.8.1 + github.com/golang-jwt/jwt/v4 v4.4.2 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.13.0 github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a diff --git a/go.sum b/go.sum index 21e963e..dc97834 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/internal/e/code.go b/internal/e/code.go index 55d37ba..e0858b4 100644 --- a/internal/e/code.go +++ b/internal/e/code.go @@ -1,19 +1,37 @@ package e const ( - Success Err = 0 - Unknown Err = 1 + Success Err = 0 + Unknown Err = 1 + InternalError Err = 100 InvalidParameter Err = 101 NotFound Err = 102 DatabaseError Err = 103 + + TokenUnknown Err = 200 + TokenEmpty Err = 201 + TokenMalformed Err = 202 + TokenTimeError Err = 203 + TokenInvalid Err = 204 + TokenSignError Err = 205 + TokenRevoked Err = 206 ) var msgText = map[Err]string{ - Success: "Success", - Unknown: "Unknown error", + Success: "Success", + Unknown: "Unknown error", + InternalError: "Internal Error", InvalidParameter: "Invalid Parameter", NotFound: "Not Found", DatabaseError: "Database 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", } diff --git a/internal/global/config.go b/internal/global/config.go index 309781e..aa1083e 100644 --- a/internal/global/config.go +++ b/internal/global/config.go @@ -1,8 +1,10 @@ package global type ConfigWebServer struct { - Address string `yaml:"Address"` - Port int `yaml:"Port"` + Address string `yaml:"Address"` + Port int `yaml:"Port"` + JwtSigningKey string `yaml:"JwtSigningKey"` + JwtExpireHour int `yaml:"JwtExpireHour"` } type ConfigRedis struct { diff --git a/internal/service/jwt/middleware.go b/internal/service/jwt/middleware.go new file mode 100644 index 0000000..c708684 --- /dev/null +++ b/internal/service/jwt/middleware.go @@ -0,0 +1,35 @@ +package jwt + +import ( + "github.com/WHUPRJ/woj-server/internal/e" + "github.com/gin-gonic/gin" + "strings" +) + +func (s *service) Handler() gin.HandlerFunc { + return func(c *gin.Context) { + tokenHeader := c.GetHeader("Authorization") + if tokenHeader == "" || !strings.HasPrefix(strings.ToLower(tokenHeader), "bearer ") { + e.Pong(c, e.TokenEmpty, nil) + c.Abort() + return + } + token := tokenHeader[7:] + + claim, err := s.ParseToken(token) + if err != e.Success { + e.Pong(c, err, nil) + c.Abort() + return + } + + // TODO: validate claim version + // if !s.Validate(claim) { + // e.Pong(c, e.TokenRevoked, nil) + // c.Abort() + // } + + c.Set("claim", claim) + c.Next() + } +} diff --git a/internal/service/jwt/model.go b/internal/service/jwt/model.go new file mode 100644 index 0000000..44d8573 --- /dev/null +++ b/internal/service/jwt/model.go @@ -0,0 +1,11 @@ +package jwt + +import "github.com/golang-jwt/jwt/v4" + +type Claim struct { + UID uint `json:"id"` + UserName string `json:"user_name"` + NickName string `json:"nick_name"` + Version int `json:"version"` + jwt.RegisteredClaims +} diff --git a/internal/service/jwt/service.go b/internal/service/jwt/service.go new file mode 100644 index 0000000..eb8f38a --- /dev/null +++ b/internal/service/jwt/service.go @@ -0,0 +1,32 @@ +package jwt + +import ( + "github.com/WHUPRJ/woj-server/internal/e" + "github.com/WHUPRJ/woj-server/internal/global" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +var _ Service = (*service)(nil) + +type Service interface { + ParseToken(tokenText string) (*Claim, e.Err) + SignClaim(claim *Claim) (string, e.Err) + // TODO: Validate(claim *Claim) bool + + Handler() gin.HandlerFunc +} + +type service struct { + log *zap.Logger + SigningKey []byte + ExpireHour int +} + +func NewJwtService(g *global.Global) Service { + return &service{ + log: g.Log, + SigningKey: []byte(g.Conf.WebServer.JwtSigningKey), + ExpireHour: g.Conf.WebServer.JwtExpireHour, + } +} diff --git a/internal/service/jwt/token.go b/internal/service/jwt/token.go new file mode 100644 index 0000000..b309a15 --- /dev/null +++ b/internal/service/jwt/token.go @@ -0,0 +1,65 @@ +package jwt + +import ( + "fmt" + "github.com/WHUPRJ/woj-server/internal/e" + "github.com/WHUPRJ/woj-server/pkg/utils" + "github.com/golang-jwt/jwt/v4" + "go.uber.org/zap" + "time" +) + +func (s *service) ParseToken(tokenText string) (*Claim, e.Err) { + if tokenText == "" { + return nil, e.TokenEmpty + } + + token, err := jwt.ParseWithClaims( + tokenText, + &Claim{}, + func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return s.SigningKey, nil + }) + + if ve, ok := err.(*jwt.ValidationError); ok { + if ve.Errors&jwt.ValidationErrorMalformed != 0 { + return nil, e.TokenMalformed + } else if ve.Errors&(jwt.ValidationErrorExpired|jwt.ValidationErrorNotValidYet) != 0 { + // Token is either expired or not active yet + return nil, e.TokenTimeError + } else { + return nil, e.TokenInvalid + } + } else if err != nil { + s.log.Warn("JWT Token Parse Error", zap.Error(err)) + return nil, e.TokenUnknown + } + + if token.Valid { + c := token.Claims.(*Claim) + return c, e.Success + } + + return nil, e.TokenInvalid +} + +func (s *service) SignClaim(claim *Claim) (string, e.Err) { + now := time.Now() + + claim.IssuedAt = jwt.NewNumericDate(now) + claim.ExpiresAt = jwt.NewNumericDate(now.Add(time.Duration(s.ExpireHour) * time.Hour)) + claim.ID = utils.RandomString(16) + // TODO: use per-user claim.Version to tracker invalidation + claim.NotBefore = jwt.NewNumericDate(time.Now()) + + token := jwt.NewWithClaims(jwt.SigningMethodHS512, claim) + ss, err := token.SignedString(s.SigningKey) + if err != nil { + s.log.Warn("jwt.SignedString error", zap.Error(err)) + return "", e.TokenSignError + } + return ss, e.Success +}