Change authcode using redis, authtoken use client secret to sign jwt
Some checks failed
Build Backend (NixCN CMS) TeamCity build failed
Build Frontend (NixCN CMS) TeamCity build finished

Signed-off-by: Asai Neko <sugar@sne.moe>
This commit is contained in:
2026-01-05 21:59:37 +08:00
parent aea7fddef0
commit b0684492fa
5 changed files with 179 additions and 91 deletions

View File

@@ -1,53 +1,68 @@
package authcode package authcode
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"sync" "nixcn-cms/data"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
type Token struct { type Token struct {
Email string ClientId string
ExpiresAt time.Time Email string
} }
var ( func NewAuthCode(clientId string, email string) (string, error) {
store = sync.Map{} ctx := context.Background()
)
// Generate magic token // generate random code
func NewAuthCode(email string) (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", err return "", err
} }
code := base64.RawURLEncoding.EncodeToString(b) code := base64.RawURLEncoding.EncodeToString(b)
key := "auth_code:" + code
store.Store(code, Token{ ttl := viper.GetDuration("ttl.auth_code_ttl")
Email: email,
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.auth_code_ttl")), // store auth code metadata in Redis
}) if err := data.Redis.HSet(
ctx,
key,
map[string]any{
"client_id": clientId,
"email": email,
},
).Err(); err != nil {
return "", err
}
// set expiration (one-time auth code)
if err := data.Redis.Expire(ctx, key, ttl).Err(); err != nil {
return "", err
}
return code, nil return code, nil
} }
// Verify magic token func VerifyAuthCode(code string) (*Token, bool) {
func VerifyAuthCode(code string) (string, bool) { ctx := context.Background()
val, ok := store.Load(code) key := "auth_code:" + code
if !ok {
return "", false // Read auth code payload
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 {
return nil, false
} }
t := val.(Token) // Delete auth code immediately (one-time use)
if time.Now().After(t.ExpiresAt) { _ = data.Redis.Del(ctx, key).Err()
store.Delete(code)
return "", false
}
store.Delete(code) return &Token{
return t.Email, true ClientId: dataMap["client_id"],
Email: dataMap["email"],
}, true
} }

View File

@@ -20,14 +20,16 @@ type Token struct {
} }
type JwtClaims struct { type JwtClaims struct {
UserID uuid.UUID `json:"user_id"` ClientId string `json:"client_id"`
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
// Generate jwt clames // Generate jwt clames
func (self *Token) NewClaims(userId uuid.UUID) JwtClaims { func (self *Token) NewClaims(clientId string, userId uuid.UUID) JwtClaims {
return JwtClaims{ return JwtClaims{
UserID: userId, ClientId: clientId,
UserID: userId,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))), ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -37,10 +39,20 @@ func (self *Token) NewClaims(userId uuid.UUID) JwtClaims {
} }
// Generate access token // Generate access token
func (self *Token) GenerateAccessToken(userId uuid.UUID) (string, error) { func (self *Token) GenerateAccessToken(clientId string, userId uuid.UUID) (string, error) {
claims := self.NewClaims(userId) claims := self.NewClaims(clientId, userId)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
secret := viper.GetString("secrets.jwt_secret")
clientData, err := new(data.Client).GetClientByClientId(clientId)
if err != nil {
return "", fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return "", fmt.Errorf("error getting decrypted secret: %v", err)
}
signedToken, err := token.SignedString([]byte(secret)) signedToken, err := token.SignedString([]byte(secret))
if err != nil { if err != nil {
return "", fmt.Errorf("error signing token: %v", err) return "", fmt.Errorf("error signing token: %v", err)
@@ -58,59 +70,73 @@ func (self *Token) GenerateRefreshToken() (string, error) {
} }
// Issue both access and refresh token // Issue both access and refresh token
func (self *Token) IssueTokens(userId uuid.UUID) (string, string, error) { func (self *Token) IssueTokens(clientId string, userId uuid.UUID) (string, string, error) {
// Gen atk // access token
access, err := self.GenerateAccessToken(userId) access, err := self.GenerateAccessToken(clientId, userId)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// Gen rtk // refresh token
refresh, err := self.GenerateRefreshToken() refresh, err := self.GenerateRefreshToken()
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// Store to redis
ctx := context.Background() ctx := context.Background()
ttl := viper.GetDuration("ttl.refresh_ttl") ttl := viper.GetDuration("ttl.refresh_ttl")
// refresh -> user refreshKey := "refresh:" + refresh
if err := data.Redis.Set(
// refresh -> user + client
if err := data.Redis.HSet(
ctx, ctx,
"refresh:"+refresh, refreshKey,
userId.String(), map[string]any{
ttl, "user_id": userId.String(),
"client_id": clientId,
},
).Err(); err != nil { ).Err(); err != nil {
return "", "", err return "", "", err
} }
if err := data.Redis.Expire(ctx, refreshKey, ttl).Err(); err != nil {
return "", "", err
}
// user -> refresh tokens // user -> refresh tokens
userSetKey := "user:" + userId.String() + ":refresh_tokens" userSetKey := "user:" + userId.String() + ":refresh_tokens"
if err := data.Redis.SAdd( if err := data.Redis.SAdd(ctx, userSetKey, refresh).Err(); err != nil {
ctx,
userSetKey,
refresh,
).Err(); err != nil {
return "", "", err return "", "", err
} }
// set user ttl >= all refresh token
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err() _ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
// client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, refresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return access, refresh, nil return access, refresh, nil
} }
// Refresh access token // Refresh access token
func (self *Token) RefreshAccessToken(refreshToken string) (string, error) { func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
// Read rtk:userid from redis
ctx := context.Background() ctx := context.Background()
key := "refresh:" + refreshToken key := "refresh:" + refreshToken
userIdStr, err := data.Redis.Get(ctx, key).Result() // read refresh token bind data
if err != nil { dataMap, err := data.Redis.HGetAll(ctx, key).Result()
return "", err if err != nil || len(dataMap) == 0 {
return "", errors.New("invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("refresh token corrupted")
} }
userId, err := uuid.Parse(userIdStr) userId, err := uuid.Parse(userIdStr)
@@ -118,75 +144,105 @@ func (self *Token) RefreshAccessToken(refreshToken string) (string, error) {
return "", err return "", err
} }
// Generate access token // Generate new access token
return self.GenerateAccessToken(userId) return self.GenerateAccessToken(clientId, userId)
} }
func (self *Token) RenewRefreshToken(refreshToken string) (string, error) { func (self *Token) RenewRefreshToken(refreshToken string) (string, error) {
ctx := context.Background() ctx := context.Background()
ttl := viper.GetDuration("ttl.refresh_ttl") ttl := viper.GetDuration("ttl.refresh_ttl")
key := "refresh:" + refreshToken oldKey := "refresh:" + refreshToken
userIdStr, err := data.Redis.Get(ctx, key).Result()
// read old refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result()
if err != nil || len(dataMap) == 0 {
return "", errors.New("invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("refresh token corrupted")
}
// generate new refresh token
newRefresh, err := self.GenerateRefreshToken()
if err != nil { if err != nil {
return "", err return "", err
} }
refresh, err := self.GenerateRefreshToken() // revoke old refresh token
if err != nil { if err := self.RevokeRefreshToken(refreshToken); err != nil {
return "", err return "", err
} }
err = self.RevokeRefreshToken(refreshToken) newKey := "refresh:" + newRefresh
if err != nil {
return "", err
}
// refresh -> user // refresh -> user + client
if err := data.Redis.Set( if err := data.Redis.HSet(
ctx, ctx,
"refresh:"+refresh, newKey,
userIdStr, map[string]any{
ttl, "user_id": userIdStr,
"client_id": clientId,
},
).Err(); err != nil { ).Err(); err != nil {
return "", err return "", err
} }
if err := data.Redis.Expire(ctx, newKey, ttl).Err(); err != nil {
return "", err
}
// user -> refresh tokens // user -> refresh tokens
userSetKey := "user:" + userIdStr + ":refresh_tokens" userSetKey := "user:" + userIdStr + ":refresh_tokens"
if err := data.Redis.SAdd(ctx, userSetKey, newRefresh).Err(); err != nil {
if err := data.Redis.SAdd(
ctx,
userSetKey,
refresh,
).Err(); err != nil {
return "", err return "", err
} }
// set user ttl >= all refresh token
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err() _ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
return refresh, nil // client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, newRefresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return newRefresh, nil
} }
func (self *Token) RevokeRefreshToken(refreshToken string) error { func (self *Token) RevokeRefreshToken(refreshToken string) error {
ctx := context.Background() ctx := context.Background()
key := "refresh:" + refreshToken refreshKey := "refresh:" + refreshToken
userIDStr, err := data.Redis.Get(ctx, key).Result() // read refresh token metadata (user_id, client_id)
if err != nil { dataMap, err := data.Redis.HGetAll(ctx, refreshKey).Result()
if err != nil || len(dataMap) == 0 {
// Token already revoked or not found
return nil return nil
} }
userSetKey := "user:" + userIDStr + ":refresh_tokens" userID := dataMap["user_id"]
clientID := dataMap["client_id"]
// Delete rtk from redis // build index keys
userSetKey := "user:" + userID + ":refresh_tokens"
clientSetKey := "client:" + clientID + ":refresh_tokens"
// remove refresh token and all related indexes atomically
pipe := data.Redis.TxPipeline() pipe := data.Redis.TxPipeline()
pipe.Del(ctx, key) // rtk:userid index
pipe.SRem(ctx, userSetKey, refreshToken) // userid:rtk index
_, err = pipe.Exec(ctx)
// remove main refresh token record
pipe.Del(ctx, refreshKey)
// remove refresh token from user's active refresh token set
pipe.SRem(ctx, userSetKey, refreshToken)
// remove refresh token from client's active refresh token set
pipe.SRem(ctx, clientSetKey, refreshToken)
_, err = pipe.Exec(ctx)
return err return err
} }
@@ -195,7 +251,6 @@ func (self *Token) HeaderVerify(header string) (string, error) {
return "", nil return "", nil
} }
jwtSecret := []byte(viper.GetString("secrets.jwt_secret"))
// Split header to 2 // Split header to 2
parts := strings.SplitN(header, " ", 2) parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" { if len(parts) != 2 || parts[0] != "Bearer" {
@@ -210,7 +265,25 @@ func (self *Token) HeaderVerify(header string) (string, error) {
tokenStr, tokenStr,
claims, claims,
func(token *jwt.Token) (any, error) { func(token *jwt.Token) (any, error) {
return jwtSecret, nil if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
if claims.ClientId == "" {
return nil, errors.New("client_id missing in token")
}
clientData, err := new(data.Client).GetClientByClientId(claims.ClientId)
if err != nil {
return nil, fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return nil, fmt.Errorf("error getting decrypted secret: %v", err)
}
return secret, nil
}, },
) )

View File

@@ -33,7 +33,7 @@ func Magic(c *gin.Context) {
return return
} }
code, err := authcode.NewAuthCode(req.Email) code, err := authcode.NewAuthCode(req.ClientId, req.Email)
if err != nil { if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"}) c.JSON(500, gin.H{"status": "code gen failed"})
} }

View File

@@ -50,7 +50,7 @@ func Redirect(c *gin.Context) {
return return
} }
code, err := authcode.NewAuthCode(user.Email) code, err := authcode.NewAuthCode(clientId, user.Email)
if err != nil { if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"}) c.JSON(500, gin.H{"status": "code gen failed"})
return return
@@ -109,7 +109,7 @@ func Redirect(c *gin.Context) {
return return
} }
newCode, err := authcode.NewAuthCode(email) newCode, err := authcode.NewAuthCode(clientId, email)
if err != nil { if err != nil {
c.JSON(500, gin.H{"status": "internal server error"}) c.JSON(500, gin.H{"status": "internal server error"})
return return

View File

@@ -22,14 +22,14 @@ func Token(c *gin.Context) {
return return
} }
email, ok := authcode.VerifyAuthCode(req.Code) authCode, ok := authcode.VerifyAuthCode(req.Code)
if !ok { if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"}) c.JSON(403, gin.H{"status": "invalid or expired token"})
return return
} }
userData := new(data.User) userData := new(data.User)
user, err := userData.GetByEmail(email) user, err := userData.GetByEmail(authCode.Email)
if err != nil { if err != nil {
c.JSON(500, gin.H{"status": "internal server error"}) c.JSON(500, gin.H{"status": "internal server error"})
return return
@@ -39,7 +39,7 @@ func Token(c *gin.Context) {
JwtTool := authtoken.Token{ JwtTool := authtoken.Token{
Application: viper.GetString("server.application"), Application: viper.GetString("server.application"),
} }
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId) accessToken, refreshToken, err := JwtTool.IssueTokens(authCode.ClientId, user.UserId)
if err != nil { if err != nil {
c.JSON(500, gin.H{"status": "error generating tokens"}) c.JSON(500, gin.H{"status": "error generating tokens"})
return return