autobrr/internal/http/auth.go
martylukyy 8120c33f6b
fix(auth): cookie expiry and renewal (#1527)
* fix(auth/web): logout when expired/invalid/no cookie is present

* fix(auth/web): specify error message in invalid cookie

* fix(auth/web): reset error boundary on login

* fix(auth/web): fix onboarding

* chore: code cleanup

* fix(web): revert tanstack/router to 1.31.0

* refactor(web): remove react-error-boundary

* feat(auth): refresh cookie when close to expiry

* enhancement(web): specify defaultError message in HttpClient

* fix(web): use absolute paths for router links (#1530)

* chore(web): bump `@tanstack/react-router` to `1.31.6`

* fix(web): settings routes

* fix(web): filter routes

* fix(web): remove unused ReleasesIndexRoute

* chore(web): add documentation for HttpClient

* chore(lint): remove unnecessary whitespace
2024-05-08 10:38:02 +02:00

210 lines
6.3 KiB
Go

// Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
// SPDX-License-Identifier: GPL-2.0-or-later
package http
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/autobrr/autobrr/internal/domain"
"github.com/autobrr/autobrr/pkg/errors"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/rs/zerolog"
)
type authService interface {
GetUserCount(ctx context.Context) (int, error)
Login(ctx context.Context, username, password string) (*domain.User, error)
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
UpdateUser(ctx context.Context, req domain.UpdateUserRequest) error
}
type authHandler struct {
log zerolog.Logger
encoder encoder
config *domain.Config
service authService
server Server
cookieStore *sessions.CookieStore
}
func newAuthHandler(encoder encoder, log zerolog.Logger, server Server, config *domain.Config, cookieStore *sessions.CookieStore, service authService) *authHandler {
return &authHandler{
log: log,
encoder: encoder,
config: config,
service: service,
cookieStore: cookieStore,
server: server,
}
}
func (h authHandler) Routes(r chi.Router) {
r.Post("/login", h.login)
r.Post("/onboard", h.onboard)
r.Get("/onboard", h.canOnboard)
// Group for authenticated routes
r.Group(func(r chi.Router) {
r.Use(h.server.IsAuthenticated)
r.Post("/logout", h.logout)
r.Get("/validate", h.validate)
r.Patch("/user/{username}", h.updateUser)
})
}
func (h authHandler) login(w http.ResponseWriter, r *http.Request) {
var data domain.User
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.encoder.StatusError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode json"))
return
}
if _, err := h.service.Login(r.Context(), data.Username, data.Password); err != nil {
h.log.Error().Err(err).Msgf("Auth: Failed login attempt username: [%s] ip: %s", data.Username, r.RemoteAddr)
h.encoder.StatusError(w, http.StatusForbidden, errors.New("could not login: bad credentials"))
return
}
// create new session
session, err := h.cookieStore.Get(r, "user_session")
if err != nil {
h.log.Error().Err(err).Msgf("Auth: Failed to create cookies with attempt username: [%s] ip: %s", data.Username, r.RemoteAddr)
h.encoder.StatusError(w, http.StatusInternalServerError, errors.New("could not create cookies"))
return
}
// Set user as authenticated
session.Values["authenticated"] = true
session.Values["created"] = time.Now().Unix()
// Set cookie options
session.Options.HttpOnly = true
session.Options.SameSite = http.SameSiteLaxMode
session.Options.Path = h.config.BaseURL
// autobrr does not support serving on TLS / https, so this is only available behind reverse proxy
// if forwarded protocol is https then set cookie secure
// SameSite Strict can only be set with a secure cookie. So we overwrite it here if possible.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
if r.Header.Get("X-Forwarded-Proto") == "https" {
session.Options.Secure = true
session.Options.SameSite = http.SameSiteStrictMode
}
if err := session.Save(r, w); err != nil {
h.encoder.StatusError(w, http.StatusInternalServerError, errors.Wrap(err, "could not save session"))
return
}
h.encoder.NoContent(w)
}
func (h authHandler) logout(w http.ResponseWriter, r *http.Request) {
// get session from context
session, ok := r.Context().Value("session").(*sessions.Session)
if !ok {
h.encoder.StatusError(w, http.StatusInternalServerError, errors.New("could not get session from context"))
return
}
if session != nil {
session.Values["authenticated"] = false
// MaxAge<0 means delete cookie immediately
session.Options.MaxAge = -1
session.Options.Path = h.config.BaseURL
if err := session.Save(r, w); err != nil {
h.log.Error().Err(err).Msgf("could not store session: %s", r.RemoteAddr)
h.encoder.StatusError(w, http.StatusInternalServerError, err)
return
}
}
h.encoder.StatusResponse(w, http.StatusNoContent, nil)
}
func (h authHandler) onboard(w http.ResponseWriter, r *http.Request) {
if status, err := h.onboardEligible(r.Context()); err != nil {
h.encoder.StatusError(w, status, err)
return
}
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.encoder.StatusError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode json"))
return
}
if err := h.service.CreateUser(r.Context(), req); err != nil {
h.encoder.StatusError(w, http.StatusForbidden, err)
return
}
// send response as ok
h.encoder.StatusResponseMessage(w, http.StatusOK, "user successfully created")
}
func (h authHandler) canOnboard(w http.ResponseWriter, r *http.Request) {
if status, err := h.onboardEligible(r.Context()); err != nil {
h.encoder.StatusError(w, status, err)
return
}
// send empty response as ok
// (client can proceed with redirection to onboarding page)
h.encoder.NoContent(w)
}
// onboardEligible checks if the onboarding process is eligible.
func (h authHandler) onboardEligible(ctx context.Context) (int, error) {
userCount, err := h.service.GetUserCount(ctx)
if err != nil {
return http.StatusInternalServerError, errors.New("could not get user count")
}
if userCount > 0 {
return http.StatusServiceUnavailable, errors.New("onboarding unavailable")
}
return http.StatusOK, nil
}
// validate sits behind the IsAuthenticated middleware which takes care of checking for a valid session
// If there is a valid session return OK, otherwise the middleware returns early with a 401
func (h authHandler) validate(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if session != nil {
h.log.Debug().Msgf("found user session: %+v", session)
}
// send empty response as ok
h.encoder.NoContent(w)
}
func (h authHandler) updateUser(w http.ResponseWriter, r *http.Request) {
var data domain.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
h.encoder.StatusError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode json"))
return
}
data.UsernameCurrent = chi.URLParam(r, "username")
if err := h.service.UpdateUser(r.Context(), data); err != nil {
h.encoder.StatusError(w, http.StatusForbidden, err)
return
}
// send response as ok
h.encoder.StatusResponseMessage(w, http.StatusOK, "user successfully updated")
}