ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
178 lines
6.1 KiB
Go
178 lines
6.1 KiB
Go
package auth
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"pipu/api/internal/platform/apperr"
|
||
"pipu/api/internal/platform/httpx"
|
||
)
|
||
|
||
type ctxKey int
|
||
|
||
const (
|
||
principalKey ctxKey = iota
|
||
requestKey
|
||
)
|
||
|
||
// requestInfo — то, что strict-обработчикам нужно от HTTP-запроса, но не входит в контракт:
|
||
// адрес клиента и cookie, которые нужно выставить в ответе.
|
||
type requestInfo struct {
|
||
ip string
|
||
cookies []*http.Cookie
|
||
}
|
||
|
||
// PrincipalFrom возвращает пользователя запроса (ok = false для публичных эндпоинтов).
|
||
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
||
p, ok := ctx.Value(principalKey).(Principal)
|
||
return p, ok
|
||
}
|
||
|
||
// WithPrincipal кладёт пользователя в контекст (для тестов других модулей).
|
||
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||
return context.WithValue(ctx, principalKey, p)
|
||
}
|
||
|
||
func clientIP(ctx context.Context) string {
|
||
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
|
||
return ri.ip
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func setCookie(ctx context.Context, c *http.Cookie) {
|
||
if ri, ok := ctx.Value(requestKey).(*requestInfo); ok {
|
||
ri.cookies = append(ri.cookies, c)
|
||
}
|
||
}
|
||
|
||
// Публичные эндпоинты /api/v1 — без access-токена.
|
||
var publicRoutes = map[string]bool{
|
||
"GET /api/v1/server-info": true,
|
||
"POST /api/v1/auth/login": true,
|
||
"POST /api/v1/auth/refresh": true,
|
||
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
|
||
"GET /api/v1/app/android/latest": true,
|
||
"GET /api/v1/app/clients": true,
|
||
}
|
||
|
||
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||
var (
|
||
credentialsChangeRoutes = map[string]bool{
|
||
"GET /api/v1/me": true,
|
||
"POST /api/v1/me/bootstrap-credentials": true,
|
||
"POST /api/v1/auth/logout": true,
|
||
}
|
||
passwordChangeRoutes = map[string]bool{
|
||
"GET /api/v1/me": true,
|
||
"POST /api/v1/me/password": true,
|
||
"POST /api/v1/auth/logout": true,
|
||
}
|
||
)
|
||
|
||
// Middleware аутентифицирует запросы к /api/v1 по Bearer-токену и ограничивает пользователей,
|
||
// обязанных сменить пароль или учётные данные (docs/auth-and-roles.md).
|
||
func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ri := &requestInfo{ip: hostOnly(r.RemoteAddr)}
|
||
ctx := context.WithValue(r.Context(), requestKey, ri)
|
||
w = &cookieWriter{ResponseWriter: w, ri: ri}
|
||
|
||
route := r.Method + " " + strings.TrimSuffix(r.URL.Path, "/")
|
||
if route == "POST /api/v1/auth/refresh" && r.ContentLength == 0 {
|
||
// Веб шлёт refresh без тела (токен в cookie), а strict-сервер oapi-codegen
|
||
// декодирует необязательное тело всё равно и отвечает 400 на пустое.
|
||
r.Body = io.NopCloser(strings.NewReader("{}"))
|
||
r.ContentLength = 2
|
||
}
|
||
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
||
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
return
|
||
}
|
||
|
||
token, ok := bearer(r.Header.Get("Authorization"))
|
||
if !ok {
|
||
httpx.WriteError(log, w, r, apperr.Unauthorized(CodeUnauthorized, "bearer token required"))
|
||
return
|
||
}
|
||
p, err := svc.Authenticate(ctx, token)
|
||
if err != nil {
|
||
httpx.WriteError(log, w, r, err)
|
||
return
|
||
}
|
||
switch {
|
||
case p.MustChangeCredentials && !credentialsChangeRoutes[route]:
|
||
httpx.WriteError(log, w, r, apperr.Forbidden(CodeCredentialsChangeRequired, "change bootstrap credentials first"))
|
||
return
|
||
case p.MustChangePassword && !passwordChangeRoutes[route]:
|
||
httpx.WriteError(log, w, r, apperr.Forbidden(CodePasswordChangeRequired, "change the temporary password first"))
|
||
return
|
||
}
|
||
if route == "POST /api/v1/admin/users/import" {
|
||
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
|
||
}
|
||
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||
}
|
||
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
||
})
|
||
}
|
||
}
|
||
|
||
func bearer(h string) (string, bool) {
|
||
const prefix = "bearer "
|
||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||
return "", false
|
||
}
|
||
t := strings.TrimSpace(h[len(prefix):])
|
||
return t, t != ""
|
||
}
|
||
|
||
func hostOnly(addr string) string {
|
||
if host, _, err := net.SplitHostPort(addr); err == nil {
|
||
return host
|
||
}
|
||
return addr
|
||
}
|
||
|
||
// cookieWriter добавляет накопленные обработчиком cookie перед записью заголовков ответа.
|
||
type cookieWriter struct {
|
||
http.ResponseWriter
|
||
ri *requestInfo
|
||
wrote bool
|
||
}
|
||
|
||
func (w *cookieWriter) flush() {
|
||
if w.wrote {
|
||
return
|
||
}
|
||
w.wrote = true
|
||
for _, c := range w.ri.cookies {
|
||
http.SetCookie(w.ResponseWriter, c)
|
||
}
|
||
}
|
||
|
||
func (w *cookieWriter) WriteHeader(code int) {
|
||
w.flush()
|
||
w.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func (w *cookieWriter) Write(b []byte) (int, error) {
|
||
w.flush()
|
||
return w.ResponseWriter.Write(b)
|
||
}
|
||
|
||
// Unwrap нужен http.ResponseController (flush, дедлайны) для обёрнутого writer.
|
||
func (w *cookieWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|