feat: 优化web
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user