Add oauth2 like auth service
All checks were successful
Build Backend (NixCN CMS) TeamCity build finished
Build Frontend (NixCN CMS) TeamCity build finished

Signed-off-by: Asai Neko <sugar@sne.moe>
This commit is contained in:
2026-01-02 15:57:42 +08:00
parent 62da1e096e
commit a98ab26fa4
12 changed files with 292 additions and 83 deletions

View File

@@ -25,6 +25,7 @@ email:
secrets: secrets:
jwt_secret: example jwt_secret: example
turnstile_secret: example turnstile_secret: example
client_secret_key: example
ttl: ttl:
magic_link_ttl: 10m magic_link_ttl: 10m
access_ttl: 15s access_ttl: 15s

View File

@@ -47,6 +47,7 @@ type email struct {
type secrets struct { type secrets struct {
JwtSecret string `yaml:"jwt_secret"` JwtSecret string `yaml:"jwt_secret"`
TurnstileSecret string `yaml:"turnstile_secret"` TurnstileSecret string `yaml:"turnstile_secret"`
ClientSecretKey string `yaml:"client_secret_key"`
} }
type ttl struct { type ttl struct {

View File

@@ -1 +1,91 @@
package data package data
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"nixcn-cms/internal/cryptography"
"strings"
"github.com/google/uuid"
"github.com/spf13/viper"
"gorm.io/datatypes"
)
type Client struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
}
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
var client Client
if err := Database.
Where("client_id = ?", clientId).
First(&client).Error; err != nil {
return nil, err
}
return &client, nil
}
func (self *Client) GetDecryptedSecret() (string, error) {
secretKey := viper.GetString("secrets.client_secret_key")
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
return string(secret), err
}
type ClientParams struct {
ClientId string
ClientName string
RedirectUri []string
}
func (self *Client) Create(params *ClientParams) (*Client, error) {
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
if err != nil {
return nil, err
}
encKey := viper.GetString("secrets.client_secret_key")
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return nil, err
}
clientSecret := base64.RawURLEncoding.EncodeToString(b)
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
if err != nil {
return nil, err
}
client := &Client{
UUID: uuid.New(),
ClientId: params.ClientId,
ClientSecret: encryptedSecret,
ClientName: params.ClientName,
RedirectUri: jsonRedirectUri,
}
if err := Database.Create(&client).Error; err != nil {
return nil, err
}
return client, nil
}
func (self *Client) ValidateRedirectURI(redirectURI string) error {
var uris []string
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
return err
}
for _, prefix := range uris {
if strings.HasPrefix(redirectURI, prefix) {
return nil
}
}
return errors.New("redirect uri not match")
}

View File

@@ -35,7 +35,7 @@ func Init() {
} }
// Auto migrate // Auto migrate
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}) err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
if err != nil { if err != nil {
log.Error("[Database] Error migrating database: ", err) log.Error("[Database] Error migrating database: ", err)
} }

View File

@@ -1,7 +1,7 @@
package middleware package middleware
import ( import (
"nixcn-cms/internal/cryptography" "nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -11,8 +11,8 @@ func JWTAuth(required bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
token := new(cryptography.Token) authtoken := new(authtoken.Token)
uid, err := token.HeaderVerify(auth) uid, err := authtoken.HeaderVerify(auth)
if err != nil { if err != nil {
c.JSON(401, gin.H{"status": err.Error()}) c.JSON(401, gin.H{"status": err.Error()})
c.Abort() c.Abort()

View File

@@ -25,29 +25,29 @@ func NewAuthCode(email string) (string, error) {
return "", err return "", err
} }
token := base64.RawURLEncoding.EncodeToString(b) code := base64.RawURLEncoding.EncodeToString(b)
store.Store(token, Token{ store.Store(code, Token{
Email: email, Email: email,
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")), ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
}) })
return token, nil return code, nil
} }
// Verify magic token // Verify magic token
func VerifyAuthCode(token string) (string, bool) { func VerifyAuthCode(code string) (string, bool) {
val, ok := store.Load(token) val, ok := store.Load(code)
if !ok { if !ok {
return "", false return "", false
} }
t := val.(Token) t := val.(Token)
if time.Now().After(t.ExpiresAt) { if time.Now().After(t.ExpiresAt) {
store.Delete(token) store.Delete(code)
return "", false return "", false
} }
store.Delete(token) store.Delete(code)
return t.Email, true return t.Email, true
} }

View File

@@ -1,4 +1,4 @@
package cryptography package authtoken
import ( import (
"context" "context"

View File

@@ -1,9 +1,13 @@
package auth package auth
import "github.com/gin-gonic/gin" import (
"nixcn-cms/middleware"
"github.com/gin-gonic/gin"
)
func Handler(r *gin.RouterGroup) { func Handler(r *gin.RouterGroup) {
r.GET("/redirect", Redirect) r.GET("/redirect", Redirect, middleware.JWTAuth(false))
r.POST("/magic", Magic) r.POST("/magic", Magic)
r.POST("/refresh", Refresh) r.POST("/refresh", Refresh)
r.POST("/token", Token) r.POST("/token", Token)

View File

@@ -1,15 +1,12 @@
package auth package auth
import ( import (
"nixcn-cms/data" "net/url"
"nixcn-cms/internal/cryptography"
"nixcn-cms/pkgs/authcode" "nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email" "nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/turnstile" "nixcn-cms/pkgs/turnstile"
"github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -43,15 +40,23 @@ func Magic(c *gin.Context) {
c.JSON(500, gin.H{"status": "code gen failed"}) c.JSON(500, gin.H{"status": "code gen failed"})
} }
uri := viper.GetString("server.external_url") + externalUrl := viper.GetString("server.external_url")
"/api/v1/auth/redirect?" + url, err := url.Parse(externalUrl)
"code=" + code + if err != nil {
"&redirect_uri=" + req.RedirectUri + c.JSON(500, gin.H{"status": "invalid external url"})
"&state=" + req.State }
debugMode := viper.GetString("server.debug_mode") url.Path = "/api/v1/auth/redirect"
if debugMode == "true" { query := url.Query()
log.Info("Magic link for " + req.Email + " : " + uri) query.Set("code", code)
query.Set("redirect_uri", req.RedirectUri)
query.Set("state", req.State)
url.RawQuery = query.Encode()
debugMode := viper.GetBool("server.debug_mode")
if debugMode {
c.JSON(200, gin.H{"status": "magiclink sent", "uri": url.String()})
return
} else { } else {
// Send email using resend // Send email using resend
resend, err := email.NewResendClient() resend, err := email.NewResendClient()
@@ -63,61 +68,9 @@ func Magic(c *gin.Context) {
resend.Send( resend.Send(
req.Email, req.Email,
"NixCN CMS Email Verify", "NixCN CMS Email Verify",
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+uri+">"+uri+"</a>", "<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",
) )
} }
c.JSON(200, gin.H{"status": "magic link sent"}) c.JSON(200, gin.H{"status": "magic link sent"})
} }
func VerifyMagicLink(c *gin.Context) {
// Get token from url
magicToken := c.Query("token")
if magicToken == "" {
c.JSON(400, gin.H{"error": "missing token"})
return
}
// Verify email token
email, ok := authcode.VerifyAuthCode(magicToken)
if !ok {
c.JSON(401, gin.H{"error": "invalid or expired token"})
return
}
// Verify if user exists
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.PermissionLevel = 10
if err := user.Create(); err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
} else {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
}
// Generate jwt
JwtTool := cryptography.Token{
Application: viper.GetString("server.application"),
}
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
if err != nil {
c.JSON(500, gin.H{"status": "error generating tokens"})
return
}
c.JSON(200, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
}

View File

@@ -1,7 +1,122 @@
package auth package auth
import "github.com/gin-gonic/gin" import (
"net/url"
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func Redirect(c *gin.Context) { func Redirect(c *gin.Context) {
clientId := c.Query("client_id")
if clientId == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
redirectUri := c.Query("redirect_uri")
if redirectUri == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
state := c.Query("state")
if state == "" {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
code := c.Query("code")
if code == "" {
userIdOrig, ok := c.Get("user_id")
if !ok || userIdOrig == "" {
c.JSON(401, gin.H{"status": "unauthorized"})
return
}
userId, err := uuid.Parse(userIdOrig.(string))
if err != nil {
c.JSON(500, gin.H{"status": "failed to parse uuid"})
return
}
userData := new(data.User)
user, err := userData.GetByUserId(userId)
if err != nil {
c.JSON(500, gin.H{"status": "failed to get user id"})
return
}
code, err := authcode.NewAuthCode(user.Email)
if err != nil {
c.JSON(500, gin.H{"status": "code gen failed"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
}
// Verify email token
email, ok := authcode.VerifyAuthCode(code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
// Verify if user exists
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create user
user.UUID = uuid.New()
user.UserId = uuid.New()
user.Email = email
user.PermissionLevel = 10
if err := user.Create(); err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
} else {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
}
clientData := new(data.Client)
client, err := clientData.GetClientByClientId(clientId)
if err != nil {
c.JSON(400, gin.H{"status": "client not found"})
return
}
err = client.ValidateRedirectURI(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "redirect uri not match"})
return
}
url, err := url.Parse(redirectUri)
if err != nil {
c.JSON(400, gin.H{"status": "invalid redirect uri"})
return
}
query := url.Query()
query.Set("code", code)
url.RawQuery = query.Encode()
c.Redirect(302, url.String())
} }

View File

@@ -1,7 +1,7 @@
package auth package auth
import ( import (
"nixcn-cms/internal/cryptography" "nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
return return
} }
JwtTool := cryptography.Token{ JwtTool := authtoken.Token{
Application: viper.GetString("server.application"), Application: viper.GetString("server.application"),
} }

View File

@@ -1,7 +1,52 @@
package auth package auth
import "github.com/gin-gonic/gin" import (
"nixcn-cms/data"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
type TokenRequest struct {
Code string `json:"code"`
}
func Token(c *gin.Context) { func Token(c *gin.Context) {
var req TokenRequest
err := c.ShouldBindJSON(&req)
if err != nil {
c.JSON(400, gin.H{"status": "invalid request"})
return
}
email, ok := authcode.VerifyAuthCode(req.Code)
if !ok {
c.JSON(403, gin.H{"status": "invalid or expired token"})
return
}
userData := new(data.User)
user, err := userData.GetByEmail(email)
if err != nil {
c.JSON(500, gin.H{"status": "internal server error"})
return
}
// Generate jwt
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
if err != nil {
c.JSON(500, gin.H{"status": "error generating tokens"})
return
}
c.JSON(200, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
})
} }