222 lines
7.0 KiB
Go
222 lines
7.0 KiB
Go
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,
|
||
},
|
||
})
|
||
}
|