diff --git a/config.default.yaml b/config.default.yaml index a36c54f..2b94f6f 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -25,6 +25,7 @@ email: secrets: jwt_secret: example turnstile_secret: example + client_secret_key: example ttl: magic_link_ttl: 10m access_ttl: 15s diff --git a/config/types.go b/config/types.go index efc768a..3ddc4dc 100644 --- a/config/types.go +++ b/config/types.go @@ -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 { diff --git a/data/client.go b/data/client.go index 0ad59c2..e372a30 100644 --- a/data/client.go +++ b/data/client.go @@ -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") +} diff --git a/data/data.go b/data/data.go index d4a6ee9..0af195a 100644 --- a/data/data.go +++ b/data/data.go @@ -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) } diff --git a/middleware/jwt.go b/middleware/jwt.go index 21b29e8..afc0be0 100644 --- a/middleware/jwt.go +++ b/middleware/jwt.go @@ -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() diff --git a/pkgs/authcode/authcode.go b/pkgs/authcode/authcode.go index f41543b..7113d3d 100644 --- a/pkgs/authcode/authcode.go +++ b/pkgs/authcode/authcode.go @@ -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 } diff --git a/internal/cryptography/token.go b/pkgs/authtoken/token.go similarity index 99% rename from internal/cryptography/token.go rename to pkgs/authtoken/token.go index 612b079..2fbdde4 100644 --- a/internal/cryptography/token.go +++ b/pkgs/authtoken/token.go @@ -1,4 +1,4 @@ -package cryptography +package authtoken import ( "context" diff --git a/service/auth/handler.go b/service/auth/handler.go index b28293c..e146a36 100644 --- a/service/auth/handler.go +++ b/service/auth/handler.go @@ -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) diff --git a/service/auth/magic.go b/service/auth/magic.go index e95787b..f887f0a 100644 --- a/service/auth/magic.go +++ b/service/auth/magic.go @@ -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", - "

Click the link below to verify your email. This link will expire in 10 minutes.

"+uri+"", + "

Click the link below to verify your email. This link will expire in 10 minutes.

"+url.String()+"", ) } 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, - }) -} diff --git a/service/auth/redirect.go b/service/auth/redirect.go index 5f7a1fc..a74830f 100644 --- a/service/auth/redirect.go +++ b/service/auth/redirect.go @@ -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()) } diff --git a/service/auth/refresh.go b/service/auth/refresh.go index a619afe..fc441b1 100644 --- a/service/auth/refresh.go +++ b/service/auth/refresh.go @@ -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"), } diff --git a/service/auth/token.go b/service/auth/token.go index fb0fd54..a829fd8 100644 --- a/service/auth/token.go +++ b/service/auth/token.go @@ -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, + }) }