Files
smart-go/internal/auth/handler/login.go
T
2026-04-23 18:58:13 +08:00

190 lines
5.9 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 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
}
}