Files
SproutGate/sproutgate-backend/internal/handlers/auth_login.go
2026-05-13 12:19:36 +08:00

222 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"sproutgate-backend/internal/auth"
"sproutgate-backend/internal/clientgeo"
"sproutgate-backend/internal/models"
)
// Login
// @Summary 账号密码登录
// @Description 可选 Turnstile开启时 body 需带 turnstileToken。
// @Tags auth
// @Accept json
// @Produce json
// @Param body body LoginRequest true "登录请求"
// @Success 200 {object} map[string]interface{} "token、expiresAt、user"
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/login [post]
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
h.store.MaybeSyncRuntimeConfigFromDB()
req.Account = strings.TrimSpace(req.Account)
if req.Account == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
return
}
if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled {
if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
user, found, err := h.store.GetUser(req.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if user.Banned {
writeBanJSON(c, user.BanReason)
return
}
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, user.TokenEpoch, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
if cid, ok := models.NormalizeAuthClientID(req.ClientID); ok {
name := models.ClampAuthClientName(req.ClientName)
if rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil {
user = rec
}
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"expiresAt": expiresAt.Format(time.RFC3339),
"user": user.OwnerPublic(),
})
}
// Verify
// @Summary 校验 JWT
// @Description 校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。
// @Tags auth
// @Accept json
// @Produce json
// @Param body body VerifyRequest true "token"
// @Success 200 {object} map[string]interface{} "valid、user"
// @Failure 400 {object} map[string]interface{}
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/verify [post]
func (h *Handler) Verify(c *gin.Context) {
var req VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
return
}
user, found, err := h.store.GetUser(claims.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"})
return
}
if user.Banned {
writeBanJSON(c, user.BanReason)
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
_, _ = h.store.RecordAuthClient(claims.Account, cid, cname)
}
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
}
// Me
// @Summary 当前用户(含签到摘要)
// @Description 需要 Authorization: Bearer。可选头X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Visit-Ip header string false "访客 IP"
// @Param X-Visit-Location header string false "访客地区展示文案"
// @Param X-Auth-Client header string false "接入应用 ID"
// @Param X-Auth-Client-Name header string false "接入应用名称"
// @Success 200 {object} map[string]interface{} "user、checkIn"
// @Failure 401 {object} map[string]interface{}
// @Failure 500 {object} map[string]interface{}
// @Router /api/auth/me [get]
func (h *Handler) Me(c *gin.Context) {
token := bearerToken(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
user, found, err := h.store.GetUser(claims.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !found {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if abortIfUserBanned(c, user) {
return
}
if abortIfTokenEpochStale(c, claims, user) {
return
}
if cid, cname, ok := authClientFromHeaders(c); ok {
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
user = rec
}
}
today := models.CurrentActivityDate()
nowAt := models.CurrentActivityTime()
user, _, err = h.store.RecordVisit(claims.Account, today, nowAt)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit"})
return
}
visitIP := strings.TrimSpace(c.GetHeader("X-Visit-Ip"))
visitLoc := strings.TrimSpace(c.GetHeader("X-Visit-Location"))
if visitIP == "" {
visitIP = strings.TrimSpace(c.ClientIP())
}
lookupURL := strings.TrimSpace(os.Getenv("GEO_LOOKUP_URL"))
if lookupURL == "" {
lookupURL = clientgeo.DefaultLookupURL
}
if visitLoc == "" && visitIP != "" {
if loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil {
visitLoc = loc
}
}
if visitIP != "" || visitLoc != "" {
user, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit meta"})
return
}
}
checkInConfig := h.store.CheckInConfig()
c.JSON(http.StatusOK, gin.H{
"user": user.OwnerPublic(),
"checkIn": gin.H{
"rewardCoins": checkInConfig.RewardCoins,
"checkedInToday": user.LastCheckInDate == today,
"lastCheckInDate": user.LastCheckInDate,
"lastCheckInAt": user.LastCheckInAt,
"today": today,
},
})
}