From cd2bcd597c79f96746282cdf5368871e7dbf2f62 Mon Sep 17 00:00:00 2001 From: Asai Neko Date: Wed, 24 Dec 2025 20:43:19 +0800 Subject: [PATCH] Add authentication function Signed-off-by: Asai Neko --- config.default.yaml | 10 ++- config/types.go | 17 ++++++ internal/crypto/jwt/jwt.go | 2 +- pkgs/email/resend.go | 87 +++++++++++++++++++++++++++ pkgs/magiclink/magiclink.go | 51 ++++++++++++++++ pkgs/turnstile/turnstile.go | 34 +++++++++++ server/router.go | 4 +- service/auth/handler.go | 8 +++ service/auth/magic.go | 80 ++++++++++++++++++++++++ service/check/checkin.go | 5 -- service/{check => checkin}/handler.go | 5 +- 11 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 pkgs/email/resend.go create mode 100644 pkgs/magiclink/magiclink.go create mode 100644 pkgs/turnstile/turnstile.go create mode 100644 service/auth/handler.go create mode 100644 service/auth/magic.go delete mode 100644 service/check/checkin.go rename service/{check => checkin}/handler.go (57%) diff --git a/config.default.yaml b/config.default.yaml index a48c7b6..e90b85d 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -1,11 +1,19 @@ server: address: :8000 + external_url: https://example.com debug_mode: false file_logger: false - jwt_secret: someting database: type: postgres host: 127.0.0.1 name: postgres username: postgres password: postgres +email: + resend_api_key: abc + from: +secrets: + jwt: something + turnstile: something +ttl: + magic_link: 15000 diff --git a/config/types.go b/config/types.go index 3889a41..6a30d14 100644 --- a/config/types.go +++ b/config/types.go @@ -3,6 +3,9 @@ package config type config struct { Server server `yaml:"server"` Database database `yaml:"database"` + Email email `yaml:"email"` + Secrets secrets `yaml:"secrets"` + TTL ttl `yaml:"ttl"` } type server struct { @@ -19,3 +22,17 @@ type database struct { Username string `yaml:"username"` Password string `yaml:"password"` } + +type email struct { + ResendApiKey string `yaml:"resend_api_key"` + From string `yaml:"from"` +} + +type secrets struct { + jwt string `yaml:"jwt"` + turnstile string `yaml:"turnstile"` +} + +type ttl struct { + magic_token string `yaml:"magin_token"` +} diff --git a/internal/crypto/jwt/jwt.go b/internal/crypto/jwt/jwt.go index 721b0eb..bb5cbc5 100644 --- a/internal/crypto/jwt/jwt.go +++ b/internal/crypto/jwt/jwt.go @@ -17,7 +17,7 @@ type Claims struct { } func JWTAuth() gin.HandlerFunc { - var JwtSecret = []byte(viper.GetString("server.jwt_secret")) + var JwtSecret = []byte(viper.GetString("secrets.jwt")) return func(c *gin.Context) { auth := c.GetHeader("Authorization") if auth == "" { diff --git a/pkgs/email/resend.go b/pkgs/email/resend.go new file mode 100644 index 0000000..d685511 --- /dev/null +++ b/pkgs/email/resend.go @@ -0,0 +1,87 @@ +package email + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/spf13/viper" +) + +type Client struct { + apiKey string + http *http.Client +} + +// Resend service client +func NewResendClient() (*Client, error) { + key := viper.GetString("email.resend_api_key") + if key == "" { + return nil, errors.New("RESEND_API_KEY not set") + } + + return &Client{ + apiKey: key, + http: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil +} + +type sendEmailRequest struct { + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + HTML string `json:"html,omitempty"` + Text string `json:"text,omitempty"` +} + +type sendEmailResponse struct { + ID string `json:"id"` +} + +// Send email by resend API +func (c *Client) Send(to, subject, html string) (string, error) { + reqBody := sendEmailRequest{ + From: viper.GetString("email.from"), + To: []string{to}, + Subject: subject, + HTML: html, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequest( + http.MethodPost, + "https://api.resend.com/emails", + bytes.NewReader(body), + ) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return "", errors.New("resend send failed") + } + + var res sendEmailResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return "", err + } + + return res.ID, nil +} diff --git a/pkgs/magiclink/magiclink.go b/pkgs/magiclink/magiclink.go new file mode 100644 index 0000000..44b037b --- /dev/null +++ b/pkgs/magiclink/magiclink.go @@ -0,0 +1,51 @@ +package magiclink + +import ( + "crypto/rand" + "encoding/base64" + "sync" + "time" +) + +type Token struct { + Email string + ExpiresAt time.Time +} + +var ( + store = sync.Map{} +) + +// Generate magic token +func NewMagicToken(email string, ttl time.Duration) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + + token := base64.RawURLEncoding.EncodeToString(b) + + store.Store(token, Token{ + Email: email, + ExpiresAt: time.Now().Add(ttl), + }) + + return token, nil +} + +// Verify magic token +func VerifyMagicToken(token string) (string, bool) { + val, ok := store.Load(token) + if !ok { + return "", false + } + + t := val.(Token) + if time.Now().After(t.ExpiresAt) { + store.Delete(token) + return "", false + } + + store.Delete(token) + return t.Email, true +} diff --git a/pkgs/turnstile/turnstile.go b/pkgs/turnstile/turnstile.go new file mode 100644 index 0000000..9b07b41 --- /dev/null +++ b/pkgs/turnstile/turnstile.go @@ -0,0 +1,34 @@ +package turnstile + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/spf13/viper" +) + +func VerifyTurnstile(token, ip string) (bool, error) { + form := url.Values{} + form.Set("secret", viper.GetString("secrets.turnstile")) + form.Set("response", token) + form.Set("remoteip", ip) + + resp, err := http.PostForm( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + form, + ) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var result struct { + Success bool `json:"success"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, err + } + + return result.Success, nil +} diff --git a/server/router.go b/server/router.go index 236faac..d20166a 100644 --- a/server/router.go +++ b/server/router.go @@ -1,7 +1,7 @@ package server import ( - "nixcn-cms/service/check" + "nixcn-cms/service/auth" "github.com/gin-gonic/gin" ) @@ -9,5 +9,5 @@ import ( func Router(e *gin.Engine) { // API Services api := e.Group("/api/v1") - check.Handler(api.Group("/check")) + auth.Handler(api.Group("/auth")) } diff --git a/service/auth/handler.go b/service/auth/handler.go new file mode 100644 index 0000000..c1421e2 --- /dev/null +++ b/service/auth/handler.go @@ -0,0 +1,8 @@ +package auth + +import "github.com/gin-gonic/gin" + +func Handler(r *gin.RouterGroup) { + r.POST("/magic", RequestMagicLink) + r.GET("/magic/verify", VerifyMagicLink) +} diff --git a/service/auth/magic.go b/service/auth/magic.go new file mode 100644 index 0000000..847aa7d --- /dev/null +++ b/service/auth/magic.go @@ -0,0 +1,80 @@ +package auth + +import ( + "net/url" + "nixcn-cms/internal/crypto/jwt" + "nixcn-cms/pkgs/magiclink" + "nixcn-cms/pkgs/turnstile" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + + "github.com/gin-gonic/gin" + "github.com/spf13/viper" +) + +type MagicLinkRequest struct { + Email string `json:"email" binding:"required,email"` + TurnstileToken string `json:"turnstile_token" binding:"required"` +} + +func RequestMagicLink(c *gin.Context) { + // Parse request + var req MagicLinkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": "invalid request"}) + return + } + + // Cloudflare turnstile + ok, err := turnstile.VerifyTurnstile(req.TurnstileToken, c.ClientIP()) + if err != nil || !ok { + c.JSON(403, gin.H{"error": "turnstile failed"}) + return + } + + // Generate magic token + token, err := magiclink.NewMagicToken(req.Email, 15*time.Minute) + if err != nil { + c.JSON(500, gin.H{"error": "internal error"}) + return + } + + link, err := url.JoinPath(viper.GetString("server.external_url"), "/api/v1/auth/magic/verify?token="+token) + if err != nil { + log.Error("Magic link join failed!") + c.JSON(500, gin.H{"message": "magic link join failed"}) + return + } + + // TODO Send EMail + log.Info("Magic link:", link) + + c.JSON(200, gin.H{"message": "magic link sent"}) +} + +func VerifyMagicLink(c *gin.Context) { + // Get token from url + token := c.Query("token") + if token == "" { + c.JSON(400, gin.H{"error": "missing token"}) + return + } + + // Verify email token + email, ok := magiclink.VerifyMagicToken(token) + if !ok { + c.JSON(401, gin.H{"error": "invalid or expired token"}) + return + } + + // Generate jwt + uuid, _ := uuid.NewUUID() + jwtToken, _ := jwt.GenerateToken(uuid, "application") + + c.JSON(200, gin.H{ + "jwt_token": jwtToken, + "email": email, + }) +} diff --git a/service/check/checkin.go b/service/check/checkin.go deleted file mode 100644 index 14a3884..0000000 --- a/service/check/checkin.go +++ /dev/null @@ -1,5 +0,0 @@ -package check - -func Checkin() { - -} diff --git a/service/check/handler.go b/service/checkin/handler.go similarity index 57% rename from service/check/handler.go rename to service/checkin/handler.go index 768e739..82c99cb 100644 --- a/service/check/handler.go +++ b/service/checkin/handler.go @@ -1,4 +1,4 @@ -package check +package checkin import ( "nixcn-cms/internal/crypto/jwt" @@ -8,7 +8,4 @@ import ( func Handler(r *gin.RouterGroup) { r.Use(jwt.JWTAuth()) - r.GET("/test", func(ctx *gin.Context) { - ctx.JSON(200, gin.H{"Test": "Test"}) - }) }