181 lines
4.4 KiB
Go
181 lines
4.4 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
|
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
|
)
|
|
|
|
type authKind string
|
|
|
|
const (
|
|
authCookie authKind = "cookie"
|
|
authToken authKind = "token"
|
|
)
|
|
|
|
type principal struct {
|
|
Kind authKind
|
|
TokenName string
|
|
TokenID string
|
|
Session string
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const principalKey ctxKey = 1
|
|
|
|
func principalFrom(ctx context.Context) (principal, bool) {
|
|
p, ok := ctx.Value(principalKey).(principal)
|
|
return p, ok
|
|
}
|
|
|
|
func (h *Handler) auth(next http.HandlerFunc) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ip := httpx.ClientIP(r, h.trusted)
|
|
|
|
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
|
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
|
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
|
return
|
|
}
|
|
|
|
p, errCode, errMsg, status := h.authenticate(r, ip)
|
|
if status != 0 {
|
|
if status == http.StatusTooManyRequests {
|
|
w.Header().Set("Retry-After", "300")
|
|
}
|
|
httpx.WriteError(w, status, errCode, errMsg)
|
|
return
|
|
}
|
|
|
|
if p.Kind == authCookie && isMutating(r.Method) {
|
|
if r.Header.Get(csrfHeader) != csrfValue {
|
|
httpx.WriteError(w, http.StatusForbidden, "forbidden", "缺少 X-Nixmsg-Request 头")
|
|
return
|
|
}
|
|
}
|
|
|
|
if p.Kind == authToken && isTokenForbiddenPath(r.URL.Path) {
|
|
httpx.WriteError(w, http.StatusForbidden, "forbidden", "API 令牌无权访问该接口")
|
|
return
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), principalKey, p)
|
|
next(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func isMutating(method string) bool {
|
|
switch method {
|
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isTokenForbiddenPath(path string) bool {
|
|
if path == "/api/admin/password" {
|
|
return true
|
|
}
|
|
return path == "/api/admin/tokens" || strings.HasPrefix(path, "/api/admin/tokens/")
|
|
}
|
|
|
|
func (h *Handler) authenticate(r *http.Request, ip string) (principal, string, string, int) {
|
|
authz := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(strings.ToLower(authz), "bearer ") {
|
|
raw := strings.TrimSpace(authz[len("Bearer "):])
|
|
if raw == "" || !h.tokens.LooksLikeAPIToken(raw) {
|
|
return authFail(h, ip)
|
|
}
|
|
hash := h.tokens.HashToken(raw)
|
|
info, err := h.lookupAPIToken(r.Context(), hash)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return authFail(h, ip)
|
|
}
|
|
return principal{}, "internal", "内部错误", http.StatusInternalServerError
|
|
}
|
|
if !info.Enabled {
|
|
return authFail(h, ip)
|
|
}
|
|
h.touchLastUsed(r.Context(), info.ID)
|
|
return principal{Kind: authToken, TokenName: info.Name, TokenID: info.ID}, "", "", 0
|
|
}
|
|
|
|
c, err := r.Cookie(cookieName)
|
|
if err != nil || c.Value == "" {
|
|
return principal{}, "unauthorized", "未登录", http.StatusUnauthorized
|
|
}
|
|
hashHex := hashSessionHex(c.Value)
|
|
ok, err := h.sessionValid(r.Context(), hashHex)
|
|
if err != nil {
|
|
return principal{}, "internal", "内部错误", http.StatusInternalServerError
|
|
}
|
|
if !ok {
|
|
return principal{}, "unauthorized", "未登录", http.StatusUnauthorized
|
|
}
|
|
return principal{Kind: authCookie, Session: c.Value}, "", "", 0
|
|
}
|
|
|
|
func authFail(h *Handler, ip string) (principal, string, string, int) {
|
|
if locked, _ := h.locks.Fail(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
|
return principal{}, "rate_limited", "登录已锁定,请稍后再试", http.StatusTooManyRequests
|
|
}
|
|
return principal{}, "unauthorized", "令牌无效", http.StatusUnauthorized
|
|
}
|
|
|
|
func hashSessionHex(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func newSessionToken() (plain string, hashHex string, err error) {
|
|
buf := make([]byte, 32)
|
|
if _, err = rand.Read(buf); err != nil {
|
|
return "", "", err
|
|
}
|
|
plain = base64.RawURLEncoding.EncodeToString(buf)
|
|
sum := sha256.Sum256([]byte(plain))
|
|
return plain, hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func formatRetryAfter(d time.Duration) string {
|
|
sec := int(d.Seconds())
|
|
if sec < 1 {
|
|
sec = 1
|
|
}
|
|
return itoa(sec)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [16]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|
|
|
|
func actorString(p principal) string {
|
|
if p.Kind == authToken {
|
|
return "token:" + p.TokenName
|
|
}
|
|
return "admin"
|
|
}
|