Files

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"
}