Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/config"
|
||||
"backend/internal/model"
|
||||
"backend/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
auth *service.AuthService
|
||||
limiter *service.RateLimitService
|
||||
}
|
||||
|
||||
func NewAuthHandler(cfg *config.Config, auth *service.AuthService, limiter *service.RateLimitService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
auth: auth,
|
||||
limiter: limiter,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Config(c *gin.Context) {
|
||||
data, err := h.auth.AuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load auth config"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SendCode(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:send-code:ip:"+ip, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
if email, err := service.ValidateEmail(body.Email); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:send-code:email:"+email, 3, 10*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.auth.SendCode(c.Request.Context(), body.Email, body.Purpose); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
EmailCode string `json:"email_code"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(body.Username)
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(body.Name)
|
||||
}
|
||||
if err := h.enforceRateLimit(c, "auth:register:ip:"+clientIP(c), 10, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
emailCode := strings.TrimSpace(body.EmailCode)
|
||||
if emailCode == "" {
|
||||
emailCode = strings.TrimSpace(body.Code)
|
||||
}
|
||||
user, token, session, err := h.auth.Register(
|
||||
c.Request.Context(),
|
||||
body.Email,
|
||||
username,
|
||||
body.Password,
|
||||
body.InviteCode,
|
||||
emailCode,
|
||||
clientIP(c),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
h.writeSession(c, token, session, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
identifier := strings.TrimSpace(body.Identifier)
|
||||
if identifier == "" {
|
||||
if strings.TrimSpace(body.Email) != "" {
|
||||
identifier = strings.TrimSpace(body.Email)
|
||||
} else {
|
||||
identifier = strings.TrimSpace(body.Username)
|
||||
}
|
||||
}
|
||||
if identifier == "" || body.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "账号或密码不能为空"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:login:ip:"+ip, 20, 15*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
if normalized, err := service.ValidateLoginIdentifier(identifier); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:login:target:"+ip+":"+strings.ToLower(normalized), 8, 15*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, token, session, err := h.auth.Login(c.Request.Context(), identifier, body.Password, ip)
|
||||
if err != nil {
|
||||
if writeLoginLocked(c, err) {
|
||||
return
|
||||
}
|
||||
if err == service.ErrAuthFailed {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.writeSession(c, token, session, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
EmailCode string `json:"email_code"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
ip := clientIP(c)
|
||||
if err := h.enforceRateLimit(c, "auth:reset:ip:"+ip, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
if email, err := service.ValidateEmail(body.Email); err == nil {
|
||||
if err := h.enforceRateLimit(c, "auth:reset:email:"+email, 5, time.Hour); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
emailCode := strings.TrimSpace(body.EmailCode)
|
||||
if emailCode == "" {
|
||||
emailCode = strings.TrimSpace(body.Code)
|
||||
}
|
||||
if err := h.auth.ResetPassword(c.Request.Context(), body.Email, body.Password, emailCode, ip); err != nil {
|
||||
if writeLoginLocked(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
if err := h.enforceRateLimit(c, "auth:change-password:user:"+user.ID, 10, 30*time.Minute); err != nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
Current string `json:"current"`
|
||||
NewPassword string `json:"new_password"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"})
|
||||
return
|
||||
}
|
||||
current := strings.TrimSpace(body.CurrentPassword)
|
||||
if current == "" {
|
||||
current = strings.TrimSpace(body.Current)
|
||||
}
|
||||
next := strings.TrimSpace(body.NewPassword)
|
||||
if next == "" {
|
||||
next = body.Password
|
||||
}
|
||||
if err := h.auth.ChangePassword(c.Request.Context(), user.ID, current, next); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Checkin(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
result, err := h.auth.Checkin(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"already": result.Already,
|
||||
"awarded": result.Awarded,
|
||||
"streak": result.Streak,
|
||||
"credits": result.Credits,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Invites(c *gin.Context) {
|
||||
user := currentUser(c)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
items, err := h.auth.InviteList(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load invites"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items, "reward": h.auth.InviteReward(c.Request.Context())})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
token := service.ParseBearer(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
token = readCookie(c, h.cfg.SessionCookieName)
|
||||
}
|
||||
_ = h.auth.Logout(c.Request.Context(), token)
|
||||
c.SetCookie(h.cfg.SessionCookieName, "", -1, "/", "", h.cfg.CookieSecure, true)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userValue, ok := c.Get("current_user")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
sessionValue, ok := c.Get("current_session")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := userValue.(*model.User)
|
||||
session, _ := sessionValue.(*service.SessionPayload)
|
||||
if user == nil || session == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"detail": "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
publicUser, err := h.auth.PublicUser(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"user": publicUser,
|
||||
})
|
||||
}
|
||||
|
||||
// writeLoginLocked maps a LoginGuard lockout error to HTTP 429 with a
|
||||
// Retry-After header (mirrors Python api/auth.py:226-237). Returns true when it
|
||||
// handled the error so the caller stops processing.
|
||||
func writeLoginLocked(c *gin.Context, err error) bool {
|
||||
var locked *service.LoginLockedError
|
||||
if errors.As(err, &locked) {
|
||||
c.Header("Retry-After", strconv.Itoa(locked.RetryAfter))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": locked.Error()})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-For")); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if real := strings.TrimSpace(c.GetHeader("X-Real-Ip")); real != "" {
|
||||
return real
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) enforceRateLimit(c *gin.Context, bucket string, limit int64, window time.Duration) error {
|
||||
if h.limiter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := h.limiter.Enforce(c.Request.Context(), bucket, limit, window); err != nil {
|
||||
if errors.Is(err, service.ErrRateLimited) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"detail": err.Error()})
|
||||
return err
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "rate limiter unavailable"})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) writeSession(c *gin.Context, token string, session *service.SessionPayload, user *model.User) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(h.cfg.SessionCookieName, token, int(h.cfg.SessionTTL.Seconds()), "/", "", h.cfg.CookieSecure, true)
|
||||
publicUser, err := h.auth.PublicUser(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load user profile"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"token": token,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"user": publicUser,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user