feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
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
}
}