feat(auth): implement user and device authentication
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminSessionCookieName = authcommon.AdminSessionCookieName
|
||||
maxLoginFormBytes = 16 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
)
|
||||
|
||||
type AdminSessionService interface {
|
||||
LoginAdmin(context.Context, AdminLoginInput) (AdminLoginResult, error)
|
||||
LogoutAdmin(context.Context, string) error
|
||||
}
|
||||
|
||||
type AdminLoginInput struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type AdminLoginResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type AuthHandler struct {
|
||||
sessions AdminSessionService
|
||||
renderer *Renderer
|
||||
limiter authcommon.AttemptLimiter
|
||||
}
|
||||
|
||||
func NewAuthHandler(
|
||||
sessions AdminSessionService,
|
||||
renderer *Renderer,
|
||||
limiter authcommon.AttemptLimiter,
|
||||
) (*AuthHandler, error) {
|
||||
if sessions == nil {
|
||||
return nil, errors.New("admin session service is required")
|
||||
}
|
||||
if renderer == nil {
|
||||
return nil, errors.New("admin auth renderer is required")
|
||||
}
|
||||
if limiter == nil {
|
||||
return nil, errors.New("admin login limiter is required")
|
||||
}
|
||||
return &AuthHandler{
|
||||
sessions: sessions,
|
||||
renderer: renderer,
|
||||
limiter: limiter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterPublic(routes gin.IRoutes) {
|
||||
routes.GET("/login", SecurityHeaders(), h.LoginPage)
|
||||
routes.POST("/login", SecurityHeaders(), h.Login)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterProtected(routes gin.IRoutes) {
|
||||
routes.POST("/logout", SecurityHeaders(), h.Logout)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) LoginPage(ctx *gin.Context) {
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: token,
|
||||
Next: safeNext(ctx.Query("next")),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(ctx *gin.Context) {
|
||||
previousSessionToken := ""
|
||||
if cookie, err := ctx.Request.Cookie(
|
||||
authcommon.AdminSessionCookieName,
|
||||
); err == nil {
|
||||
previousSessionToken = cookie.Value
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(
|
||||
ctx.Writer,
|
||||
ctx.Request.Body,
|
||||
maxLoginFormBytes,
|
||||
)
|
||||
if err := ctx.Request.ParseForm(); err != nil {
|
||||
h.renderError(ctx, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
next := safeNext(ctx.PostForm("next"))
|
||||
page := loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: strings.TrimSpace(
|
||||
ctx.PostForm(authcommon.CSRFFormField),
|
||||
),
|
||||
Next: next,
|
||||
Username: strings.TrimSpace(ctx.PostForm("username")),
|
||||
}
|
||||
if !validCSRF(ctx) {
|
||||
page.Message = "登录页面已失效,请刷新后重试。"
|
||||
h.render(ctx, http.StatusForbidden, page)
|
||||
return
|
||||
}
|
||||
password := ctx.PostForm("password")
|
||||
if page.Username == "" {
|
||||
page.UsernameError = "请输入账号。"
|
||||
}
|
||||
if password == "" {
|
||||
page.PasswordError = "请输入密码。"
|
||||
}
|
||||
if page.UsernameError != "" || page.PasswordError != "" {
|
||||
page.Message = "请检查并补全必填项。"
|
||||
h.render(ctx, http.StatusUnprocessableEntity, page)
|
||||
return
|
||||
}
|
||||
|
||||
attemptKey := authcommon.LoginAttemptKey(
|
||||
"admin",
|
||||
ctx.Request.RemoteAddr,
|
||||
)
|
||||
if allowed, wait := h.limiter.Allow(attemptKey); !allowed {
|
||||
ctx.Header(
|
||||
"Retry-After",
|
||||
strconv.Itoa(authcommon.RetryAfterSeconds(wait)),
|
||||
)
|
||||
page.Message = "登录尝试次数过多,请稍后再试。"
|
||||
h.render(ctx, http.StatusTooManyRequests, page)
|
||||
return
|
||||
}
|
||||
result, err := h.sessions.LoginAdmin(
|
||||
ctx.Request.Context(),
|
||||
AdminLoginInput{
|
||||
Username: page.Username,
|
||||
Password: password,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrAccountDisabled):
|
||||
page.Message = "账号或密码不正确。"
|
||||
h.render(ctx, http.StatusUnauthorized, page)
|
||||
case errors.Is(err, context.DeadlineExceeded),
|
||||
errors.Is(err, ErrUnavailable):
|
||||
page.Message = "登录服务暂时不可用,请稍后重试。"
|
||||
h.render(ctx, http.StatusServiceUnavailable, page)
|
||||
default:
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
h.limiter.Reset(attemptKey)
|
||||
|
||||
if previousSessionToken != "" &&
|
||||
previousSessionToken != result.Token {
|
||||
if err := h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
previousSessionToken,
|
||||
); err != nil {
|
||||
_ = h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
result.Token,
|
||||
)
|
||||
clearAdminSessionCookie(ctx)
|
||||
h.renderError(ctx, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
setAdminSessionCookie(ctx, result)
|
||||
if _, err := rotateCSRFToken(ctx); err != nil {
|
||||
_ = h.sessions.LogoutAdmin(ctx.Request.Context(), result.Token)
|
||||
clearAdminSessionCookie(ctx)
|
||||
h.renderError(ctx, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, next)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(ctx *gin.Context) {
|
||||
if !validCSRF(ctx) {
|
||||
h.renderError(ctx, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cookie, err := ctx.Request.Cookie(authcommon.AdminSessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
if err := h.sessions.LogoutAdmin(
|
||||
ctx.Request.Context(),
|
||||
cookie.Value,
|
||||
); err != nil &&
|
||||
!errors.Is(err, ErrNotFound) {
|
||||
h.renderError(ctx, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
clearAdminSessionCookie(ctx)
|
||||
_, _ = rotateCSRFToken(ctx)
|
||||
ctx.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
|
||||
func setAdminSessionCookie(
|
||||
ctx *gin.Context,
|
||||
result AdminLoginResult,
|
||||
) {
|
||||
maxAge := int(time.Until(result.ExpiresAt).Seconds())
|
||||
if maxAge < 1 {
|
||||
maxAge = 1
|
||||
}
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: authcommon.AdminSessionCookieName,
|
||||
Value: result.Token,
|
||||
Path: "/",
|
||||
Expires: result.ExpiresAt.UTC(),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearAdminSessionCookie(ctx *gin.Context) {
|
||||
http.SetCookie(ctx.Writer, &http.Cookie{
|
||||
Name: authcommon.AdminSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(1, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: ctx.Request.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func safeNext(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "/tasks"
|
||||
}
|
||||
if strings.Contains(value, `\`) ||
|
||||
strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil ||
|
||||
parsed.IsAbs() ||
|
||||
parsed.Host != "" ||
|
||||
parsed.Fragment != "" ||
|
||||
path.Clean(parsed.Path) != parsed.Path {
|
||||
return "/tasks"
|
||||
}
|
||||
if parsed.Path != "/tasks" &&
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") {
|
||||
return "/tasks"
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func (h *AuthHandler) render(
|
||||
ctx *gin.Context,
|
||||
status int,
|
||||
page loginPage,
|
||||
) {
|
||||
var output bytes.Buffer
|
||||
if err := h.renderer.Execute(&output, "login", page); err != nil {
|
||||
ctx.Data(
|
||||
http.StatusInternalServerError,
|
||||
formContentType,
|
||||
[]byte("页面暂时无法显示,请稍后重试。"),
|
||||
)
|
||||
return
|
||||
}
|
||||
ctx.Data(status, formContentType, output.Bytes())
|
||||
}
|
||||
|
||||
func (h *AuthHandler) renderError(ctx *gin.Context, status int) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, status, loginPage{
|
||||
Page: pageView{Title: "管理端登录"},
|
||||
CSRFToken: token,
|
||||
Next: "/tasks",
|
||||
Message: "操作失败,请刷新页面后重试。",
|
||||
})
|
||||
}
|
||||
|
||||
type loginPage struct {
|
||||
Page pageView
|
||||
CSRFToken string
|
||||
Next string
|
||||
Username string
|
||||
Message string
|
||||
UsernameError string
|
||||
PasswordError string
|
||||
}
|
||||
Reference in New Issue
Block a user