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:
jwt_secret: example
turnstile_secret: example
client_secret_key: example
ttl:
magic_link_ttl: 10m
access_ttl: 15s

View File

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

View File

@@ -1 +1,91 @@
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
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{})
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
if err != nil {
log.Error("[Database] Error migrating database: ", err)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
package auth
import (
"nixcn-cms/data"
"nixcn-cms/internal/cryptography"
"net/url"
"nixcn-cms/pkgs/authcode"
"nixcn-cms/pkgs/email"
"nixcn-cms/pkgs/turnstile"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
@@ -43,15 +40,23 @@ func Magic(c *gin.Context) {
c.JSON(500, gin.H{"status": "code gen failed"})
}
uri := viper.GetString("server.external_url") +
"/api/v1/auth/redirect?" +
"code=" + code +
"&redirect_uri=" + req.RedirectUri +
"&state=" + req.State
externalUrl := viper.GetString("server.external_url")
url, err := url.Parse(externalUrl)
if err != nil {
c.JSON(500, gin.H{"status": "invalid external url"})
}
debugMode := viper.GetString("server.debug_mode")
if debugMode == "true" {
log.Info("Magic link for " + req.Email + " : " + uri)
url.Path = "/api/v1/auth/redirect"
query := url.Query()
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 {
// Send email using resend
resend, err := email.NewResendClient()
@@ -63,61 +68,9 @@ func Magic(c *gin.Context) {
resend.Send(
req.Email,
"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"})
}
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
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) {
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
import (
"nixcn-cms/internal/cryptography"
"nixcn-cms/pkgs/authtoken"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
return
}
JwtTool := cryptography.Token{
JwtTool := authtoken.Token{
Application: viper.GetString("server.application"),
}

View File

@@ -1,7 +1,52 @@
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) {
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,
})
}