package handler import ( "errors" "log/slog" "net/http" "time" "giter.top/smart/internal/auth/oauth2" "giter.top/smart/internal/auth/session" "giter.top/smart/internal/iam/entity" iamrepo "giter.top/smart/internal/iam/repository" "giter.top/smart/pkg/config" "giter.top/smart/pkg/utils/codec" "github.com/gin-gonic/gin" ) // LoginHandler JSON 登录:校验密码后签发 OAuth2 授权码(PKCE),并可选下发会话 Cookie(与 /oauth/authorize 兼容)。 type LoginHandler struct { cfg *config.Config users iamrepo.UserRepository sess *session.Store oauth *oauth2.Service } // NewLoginHandler 构造。 func NewLoginHandler(cfg *config.Config, users iamrepo.UserRepository, sess *session.Store, oauth *oauth2.Service) *LoginHandler { return &LoginHandler{cfg: cfg, users: users, sess: sess, oauth: oauth} } type loginBody struct { TenantID string `json:"tenant_id"` UserName string `json:"user_name"` Password string `json:"password"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` CodeChallenge string `json:"code_challenge"` CodeChallengeMethod string `json:"code_challenge_method"` State string `json:"state"` Scope string `json:"scope"` } type apiEnvelope struct { Code int `json:"code"` Msg string `json:"msg"` Data interface{} `json:"data,omitempty"` } // Login POST /api/v1/auth/login func (h *LoginHandler) Login(c *gin.Context) { var req loginBody if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "请求参数无效: " + err.Error(), Data: nil}) return } if req.UserName == "" || req.Password == "" { c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 user_name 或 password", Data: nil}) return } if req.ClientID == "" || req.RedirectURI == "" || req.CodeChallenge == "" { c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 client_id、redirect_uri 或 code_challenge", Data: nil}) return } if req.CodeChallengeMethod == "" { c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "缺少 code_challenge_method", Data: nil}) return } tid := req.TenantID if tid == "" { tid = entity.PlatformTenantID } u, err := h.users.GetByUserName(c.Request.Context(), tid, req.UserName) if err != nil { slog.Warn("auth_login_failed", "reason", "user_not_found", "tenant_id", tid, "user_name", req.UserName, "client_ip", c.ClientIP()) c.JSON(http.StatusUnauthorized, apiEnvelope{Code: 401, Msg: "用户名或密码错误", Data: nil}) return } if err := codec.VerifyPassword(req.Password, u.PasswordHash); err != nil { slog.Warn("auth_login_failed", "reason", "bad_password", "tenant_id", tid, "user_name", req.UserName, "client_ip", c.ClientIP()) c.JSON(http.StatusUnauthorized, apiEnvelope{Code: 401, Msg: "用户名或密码错误", Data: nil}) return } if u.Status != 1 { slog.Warn("auth_login_failed", "reason", "user_disabled", "tenant_id", tid, "user_id", u.ID, "client_ip", c.ClientIP()) c.JSON(http.StatusForbidden, apiEnvelope{Code: 403, Msg: "用户已禁用", Data: nil}) return } codePlain, err := h.oauth.IssueAuthorizationCodeAfterPasswordAuth( c.Request.Context(), req.ClientID, req.RedirectURI, u.ID, u.TenantID, req.Scope, req.CodeChallenge, req.CodeChallengeMethod, ) if err != nil { switch { case errors.Is(err, oauth2.ErrInvalidClient): c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "无效的 client_id", Data: nil}) return case errors.Is(err, oauth2.ErrInvalidRedirectURI): c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "redirect_uri 与客户端登记不一致", Data: nil}) return case errors.Is(err, oauth2.ErrPKCERequired): c.JSON(http.StatusOK, apiEnvelope{Code: 400, Msg: "code_challenge 或 code_challenge_method 无效(需 S256)", Data: nil}) return default: slog.Error("auth_login_issue_code", "err", err) c.JSON(http.StatusInternalServerError, apiEnvelope{Code: 500, Msg: "服务器错误", Data: nil}) return } } sid, err := h.sess.Create(c.Request.Context(), u.ID, u.TenantID) if err != nil { slog.Error("auth_login_session", "err", err) c.JSON(http.StatusInternalServerError, apiEnvelope{Code: 500, Msg: "会话创建失败", Data: nil}) return } h.setSessionCookie(c, sid) data := gin.H{ "authorization_code": codePlain, } if req.State != "" { data["state"] = req.State } slog.Info("auth_login_ok", "tenant_id", u.TenantID, "user_id", u.ID, "user_name", req.UserName, "client_ip", c.ClientIP()) c.JSON(http.StatusOK, apiEnvelope{Code: 200, Msg: "操作成功", Data: data}) } // Logout POST /api/v1/auth/logout func (h *LoginHandler) Logout(c *gin.Context) { sid, err := c.Cookie(h.cfg.Auth.Session.CookieName) if err == nil && sid != "" { _ = h.sess.Delete(c.Request.Context(), sid) } h.clearSessionCookie(c) c.JSON(http.StatusOK, apiEnvelope{Code: 200, Msg: "操作成功", Data: nil}) } func (h *LoginHandler) setSessionCookie(c *gin.Context, sid string) { same := sameSite(h.cfg.Auth.Session.SameSite) ttl := h.cfg.Auth.Session.TTL if ttl == 0 { ttl = 168 * time.Hour } http.SetCookie(c.Writer, &http.Cookie{ Name: h.cfg.Auth.Session.CookieName, Value: sid, Path: "/", Domain: h.cfg.Auth.Session.CookieDomain, MaxAge: int(ttl.Seconds()), Secure: h.cfg.Auth.Session.CookieSecure, HttpOnly: true, SameSite: same, }) } func (h *LoginHandler) clearSessionCookie(c *gin.Context) { same := sameSite(h.cfg.Auth.Session.SameSite) http.SetCookie(c.Writer, &http.Cookie{ Name: h.cfg.Auth.Session.CookieName, Value: "", Path: "/", Domain: h.cfg.Auth.Session.CookieDomain, MaxAge: -1, Secure: h.cfg.Auth.Session.CookieSecure, HttpOnly: true, SameSite: same, }) } func sameSite(s string) http.SameSite { switch s { case "strict": return http.SameSiteStrictMode case "none": return http.SameSiteNoneMode default: return http.SameSiteLaxMode } }