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