package auth import ( "context" "errors" "net/http" "time" "pipu/api/internal/api/gen" "pipu/api/internal/platform/apperr" ) // RefreshCookie — имя cookie с refresh-токеном веб-клиента. const RefreshCookie = "pipu_rt" const refreshCookiePath = "/api/v1/auth" // Handler реализует auth- и me-части gen.StrictServerInterface. type Handler struct { svc *Service cookieSecure bool } // NewHandler создаёт обработчик. cookieSecure = false допустим только для разработки по http. func NewHandler(svc *Service, cookieSecure bool) *Handler { return &Handler{svc: svc, cookieSecure: cookieSecure} } func principal(ctx context.Context) (Principal, error) { p, ok := PrincipalFrom(ctx) if !ok { return Principal{}, apperr.Unauthorized(CodeUnauthorized, "not authenticated") } return p, nil } // Login реализует gen.StrictServerInterface. func (h *Handler) Login(ctx context.Context, req gen.LoginRequestObject) (gen.LoginResponseObject, error) { in := LoginInput{ Username: req.Body.Username, Password: req.Body.Password, ClientType: ClientType(req.Body.ClientType), IP: clientIP(ctx), } if req.Body.DeviceName != nil { in.DeviceName = *req.Body.DeviceName } t, err := h.svc.Login(ctx, in) if err != nil { return nil, err } return gen.Login200JSONResponse(h.tokensDTO(ctx, t)), nil } // RefreshTokens реализует gen.StrictServerInterface. func (h *Handler) RefreshTokens(ctx context.Context, req gen.RefreshTokensRequestObject) (gen.RefreshTokensResponseObject, error) { var token string if req.Body != nil && req.Body.RefreshToken != nil { token = *req.Body.RefreshToken } else if req.Params.PipuRt != nil { token = *req.Params.PipuRt } t, err := h.svc.Refresh(ctx, token) if err != nil { var ae *apperr.Error // Токен больше не годится — убираем cookie, чтобы браузер не присылал его снова. // При гонке двух вкладок cookie уже новая, её не трогаем. if req.Params.PipuRt != nil && errors.As(err, &ae) && ae.Code != CodeRefreshRace { h.clearCookie(ctx) } return nil, err } return gen.RefreshTokens200JSONResponse(h.tokensDTO(ctx, t)), nil } // Logout реализует gen.StrictServerInterface. func (h *Handler) Logout(ctx context.Context, _ gen.LogoutRequestObject) (gen.LogoutResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } if err := h.svc.Logout(ctx, p); err != nil { return nil, err } h.clearCookie(ctx) return gen.Logout204Response{}, nil } // LogoutAll реализует gen.StrictServerInterface. func (h *Handler) LogoutAll(ctx context.Context, _ gen.LogoutAllRequestObject) (gen.LogoutAllResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } if err := h.svc.LogoutAll(ctx, p); err != nil { return nil, err } h.clearCookie(ctx) return gen.LogoutAll204Response{}, nil } // GetMe реализует gen.StrictServerInterface. func (h *Handler) GetMe(ctx context.Context, _ gen.GetMeRequestObject) (gen.GetMeResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } u, err := h.svc.Me(ctx, p) if err != nil { return nil, err } return gen.GetMe200JSONResponse(MeDTO(u)), nil } // UpdateMe реализует gen.StrictServerInterface. func (h *Handler) UpdateMe(ctx context.Context, req gen.UpdateMeRequestObject) (gen.UpdateMeResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } patch := MePatch{DisplayName: req.Body.DisplayName, Locale: req.Body.Locale} if req.Body.UiPrefs != nil { prefs := uiPrefsFromDTO(*req.Body.UiPrefs) patch.UIPrefs = &prefs } u, err := h.svc.UpdateMe(ctx, p, patch) if err != nil { return nil, err } return gen.UpdateMe200JSONResponse(MeDTO(u)), nil } // ChangePassword реализует gen.StrictServerInterface. func (h *Handler) ChangePassword(ctx context.Context, req gen.ChangePasswordRequestObject) (gen.ChangePasswordResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } t, err := h.svc.ChangePassword(ctx, p, req.Body.CurrentPassword, req.Body.NewPassword) if err != nil { return nil, err } return gen.ChangePassword200JSONResponse(h.tokensDTO(ctx, t)), nil } // SetBootstrapCredentials реализует gen.StrictServerInterface. func (h *Handler) SetBootstrapCredentials(ctx context.Context, req gen.SetBootstrapCredentialsRequestObject) (gen.SetBootstrapCredentialsResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } t, err := h.svc.SetBootstrapCredentials(ctx, p, req.Body.NewUsername, req.Body.NewPassword) if err != nil { return nil, err } return gen.SetBootstrapCredentials200JSONResponse(h.tokensDTO(ctx, t)), nil } // ListMySessions реализует gen.StrictServerInterface. func (h *Handler) ListMySessions(ctx context.Context, _ gen.ListMySessionsRequestObject) (gen.ListMySessionsResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } sessions, err := h.svc.ListSessions(ctx, p) if err != nil { return nil, err } items := make([]gen.Session, 0, len(sessions)) for _, s := range sessions { items = append(items, gen.Session{ Id: s.ID, DeviceName: s.DeviceName, ClientType: gen.ClientType(s.ClientType), CreatedAt: s.CreatedAt, LastUsedAt: s.LastUsedAt, Current: s.ID == p.SessionID, }) } return gen.ListMySessions200JSONResponse{Items: items}, nil } // RevokeMySession реализует gen.StrictServerInterface. func (h *Handler) RevokeMySession(ctx context.Context, req gen.RevokeMySessionRequestObject) (gen.RevokeMySessionResponseObject, error) { p, err := principal(ctx) if err != nil { return nil, err } if err := h.svc.RevokeSession(ctx, p, req.Id); err != nil { return nil, err } return gen.RevokeMySession204Response{}, nil } // tokensDTO собирает ответ с токенами: веб получает refresh в cookie, Android — в теле. func (h *Handler) tokensDTO(ctx context.Context, t Tokens) gen.AuthTokens { out := gen.AuthTokens{ AccessToken: t.Access, TokenType: gen.Bearer, ExpiresIn: int(t.ExpiresIn / time.Second), User: MeDTO(t.User), } if t.ClientType == ClientWeb { setCookie(ctx, &http.Cookie{ Name: RefreshCookie, Value: t.Refresh, Path: refreshCookiePath, MaxAge: int(h.svc.cfg.RefreshTTL / time.Second), HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, }) } else { refresh := t.Refresh out.RefreshToken = &refresh } return out } func (h *Handler) clearCookie(ctx context.Context) { setCookie(ctx, &http.Cookie{ Name: RefreshCookie, Path: refreshCookiePath, MaxAge: -1, HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, }) } // MeDTO переводит пользователя в схему Me. func MeDTO(u User) gen.Me { return gen.Me{ Id: u.ID, Username: u.Username, DisplayName: u.DisplayName, Role: gen.Role(u.Role), Locale: u.Locale, UiPrefs: uiPrefsDTO(u.UIPrefs), MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials, CreatedAt: u.CreatedAt, } } func uiPrefsDTO(p UIPrefs) gen.UiPrefs { return gen.UiPrefs{ Theme: (*gen.UiPrefsTheme)(p.Theme), WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant), WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer), MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer), Ambient: p.Ambient, CrossfadeS: p.CrossfadeS, Normalize: p.Normalize, } } func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs { return UIPrefs{ Theme: (*string)(p.Theme), WaveVariant: (*string)(p.WaveVariant), WebPlayer: (*string)(p.WebPlayer), MobilePlayer: (*string)(p.MobilePlayer), Ambient: p.Ambient, CrossfadeS: p.CrossfadeS, Normalize: p.Normalize, } }