refactor: remove recovery auth surface
This commit is contained in:
43
README.md
43
README.md
@@ -33,7 +33,9 @@ Technically, I also wanted to prove that a small, server-rendered Go app could s
|
||||
|
||||
## What the application offers
|
||||
|
||||
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, and full watchlist management in one server-rendered interface. It also includes account flows such as registration, login, recovery, and recovery-key rotation. The notifications area is tuned for practical tracking, including sequel visibility derived from watchlist context.
|
||||
For my own workflow, MyAnimeList combines catalog browsing, seasonal discovery, quick search, detail pages with recommendations and relations, full watchlist management, continue-watching, and in-app playback in one server-rendered interface.
|
||||
|
||||
Authentication in the web UI is login-only.
|
||||
|
||||
## Technical approach
|
||||
|
||||
@@ -49,19 +51,24 @@ Instead of treating the repository as one flat service, the codebase is organize
|
||||
| --- | --- |
|
||||
| `cmd/server` | Application entrypoint and process lifecycle setup |
|
||||
| `internal/server` | Route registration and middleware composition |
|
||||
| `internal/features/anime` | Anime browsing, discovery, search, detail, and notifications logic |
|
||||
| `internal/features/watchlist` | Watchlist updates, retrieval, import, and export |
|
||||
| `internal/features/auth` | Authentication, sessions, account recovery, and account settings |
|
||||
| `internal/features/anime` | Catalog, discovery, search, details, recommendations, and relations |
|
||||
| `internal/features/watchlist` | Watchlist updates, retrieval, import/export, and continue-watching |
|
||||
| `internal/features/playback` | Watch page, stream/subtitle proxying, and watch progress APIs |
|
||||
| `internal/features/auth` | Login/session handling and auth service logic |
|
||||
| `internal/jikan` | Upstream API client, caching, and retry-aware fetch behavior |
|
||||
| `internal/worker` | Background relation sync, retry processing, and cache cleanup |
|
||||
| `internal/database` | Migration runner, generated query layer, and DB models |
|
||||
| `internal/templates` | Server-rendered page and partial templates |
|
||||
| `internal/watchorder` | Watch-order scraping and parsing helpers |
|
||||
| `migrations` | Schema evolution and operational DB changes |
|
||||
| `static` | CSS, JavaScript, and static assets |
|
||||
| `static` | Source CSS, TypeScript, and static assets |
|
||||
| `dist` | Built frontend assets served at `/dist/*` |
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
On startup, the server opens SQLite using `DATABASE_FILE` (defaulting to `mal.db`), runs migrations automatically, initializes core services, starts the background worker, and then serves HTTP traffic on `PORT` (defaulting to `3000`). A request enters the router, passes through global middleware for origin and authentication boundaries, reaches a feature handler, and then resolves through service logic that combines database access with upstream data where needed before rendering HTML.
|
||||
On startup, the server opens SQLite using `DATABASE_FILE` (defaulting to `mal.db`), runs migrations automatically, initializes core services, starts the background worker, and then serves HTTP traffic on `PORT` (defaulting to `3000`). A request enters the router, passes through global middleware for origin and auth boundaries, reaches a feature handler, and then resolves through service logic that combines database access with upstream data where needed before rendering HTML.
|
||||
|
||||
Public access is intentionally limited. `/`, `/login`, `/search`, `/api/search`, `/api/search-quick`, and static asset routes are available without auth; most other routes require a valid session.
|
||||
|
||||
The background worker continuously maintains relation data for sequel awareness, processes queued retryable anime fetches, and periodically removes expired cache records. This keeps user-facing pages stable even when data collection has to happen in multiple phases.
|
||||
|
||||
@@ -73,7 +80,7 @@ There are still honest limits. Metadata quality still depends partly on external
|
||||
|
||||
## Getting started
|
||||
|
||||
For local development, install Go `1.24+`, SQLite, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server.
|
||||
For local development, install Go `1.24+`, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server.
|
||||
|
||||
```bash
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
@@ -87,13 +94,27 @@ The frontend pipeline uses a single source stylesheet (`static/style.css`) and T
|
||||
|
||||
When the server starts, the app is available at `http://localhost:3000`.
|
||||
|
||||
For containerized usage, the included `Dockerfile` uses a multi-stage build that generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support.
|
||||
Important notes:
|
||||
- Environment variables are read directly from the process environment (`PORT`, `DATABASE_FILE`, `ENV`); `.env` is not auto-loaded.
|
||||
- The web app currently exposes a login route only. If your database has no users yet, create the first user outside the web UI.
|
||||
|
||||
For containerized usage, the included `Dockerfile` uses a multi-stage build that installs Bun + templ, builds assets, generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support.
|
||||
|
||||
```bash
|
||||
docker build -t myanimelist .
|
||||
docker run --rm -p 3000:3000 myanimelist
|
||||
```
|
||||
|
||||
For persistent data in containers, set `DATABASE_FILE` to `/app/data/mal.db` and mount a volume:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_FILE=/app/data/mal.db \
|
||||
-v "$(pwd)/data:/app/data" \
|
||||
myanimelist
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -104,14 +125,16 @@ docker run --rm -p 3000:3000 myanimelist
|
||||
|
||||
## Database and testing
|
||||
|
||||
Migrations run at startup, so schema changes are applied automatically before the server begins accepting traffic. Migration history includes the initial auth and watchlist schema, anime metadata expansion, relation tracking, Jikan cache persistence, indexing updates, recovery key support, and retry-queue support for failed fetches.
|
||||
Migrations run at startup, so schema changes are applied automatically before the server begins accepting traffic. Migration history includes the initial auth and watchlist schema, anime metadata expansion, relation tracking, Jikan cache persistence, indexing updates, and retry-queue support for failed fetches.
|
||||
|
||||
Tests are available for watchlist behavior, relation helpers, auth middleware boundaries, and watch-order parsing. Run the full test suite with:
|
||||
Current automated tests are unit-focused and cover watchlist behavior, relation helpers, auth middleware boundaries, and watch-order parsing. Run the full test suite with:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
There is currently no CI workflow in this repository, so validation is local.
|
||||
|
||||
## Contributing
|
||||
|
||||
This is primarily a personal project, so development priorities are driven by my own use and preferences. That said, if you spot a bug or have a focused improvement, feel free to open an issue or pull request. Please read `CONTRIBUTING.md` first so expectations around scope, validation, and security handling are clear.
|
||||
|
||||
@@ -80,7 +80,6 @@ type User struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RecoveryKeyHash string `json:"recovery_key_hash"`
|
||||
}
|
||||
|
||||
type WatchListEntry struct {
|
||||
|
||||
@@ -30,7 +30,6 @@ type Querier interface {
|
||||
GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error)
|
||||
GetUser(ctx context.Context, id string) (User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||
GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg GetUserByUsernameAndRecoveryKeyHashParams) (User, error)
|
||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
||||
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||
@@ -39,7 +38,6 @@ type Querier interface {
|
||||
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||
SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error
|
||||
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
|
||||
UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error
|
||||
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
||||
UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error
|
||||
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
|
||||
|
||||
@@ -5,18 +5,10 @@ SELECT * FROM user WHERE id = ? LIMIT 1;
|
||||
SELECT * FROM user WHERE username = ? LIMIT 1;
|
||||
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO user (id, username, password_hash, recovery_key_hash)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO user (id, username, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUserByUsernameAndRecoveryKeyHash :one
|
||||
SELECT * FROM user WHERE username = ? AND recovery_key_hash = ? LIMIT 1;
|
||||
|
||||
-- name: UpdateUserPasswordAndRecoveryKeyHash :exec
|
||||
UPDATE user
|
||||
SET password_hash = ?, recovery_key_hash = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: CreateSession :one
|
||||
INSERT INTO session (id, user_id, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
|
||||
@@ -49,32 +49,25 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO user (id, username, password_hash, recovery_key_hash)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, username, password_hash, created_at, recovery_key_hash
|
||||
INSERT INTO user (id, username, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, username, password_hash, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
RecoveryKeyHash string `json:"recovery_key_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser,
|
||||
arg.ID,
|
||||
arg.Username,
|
||||
arg.PasswordHash,
|
||||
arg.RecoveryKeyHash,
|
||||
)
|
||||
row := q.db.QueryRowContext(ctx, createUser, arg.ID, arg.Username, arg.PasswordHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.RecoveryKeyHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -499,7 +492,7 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE id = ? LIMIT 1
|
||||
SELECT id, username, password_hash, created_at FROM user WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
|
||||
@@ -510,13 +503,12 @@ func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.RecoveryKeyHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE username = ? LIMIT 1
|
||||
SELECT id, username, password_hash, created_at FROM user WHERE username = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
@@ -527,29 +519,6 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.RecoveryKeyHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsernameAndRecoveryKeyHash = `-- name: GetUserByUsernameAndRecoveryKeyHash :one
|
||||
SELECT id, username, password_hash, created_at, recovery_key_hash FROM user WHERE username = ? AND recovery_key_hash = ? LIMIT 1
|
||||
`
|
||||
|
||||
type GetUserByUsernameAndRecoveryKeyHashParams struct {
|
||||
Username string `json:"username"`
|
||||
RecoveryKeyHash string `json:"recovery_key_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg GetUserByUsernameAndRecoveryKeyHashParams) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByUsernameAndRecoveryKeyHash, arg.Username, arg.RecoveryKeyHash)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.CreatedAt,
|
||||
&i.RecoveryKeyHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -807,23 +776,6 @@ func (q *Queries) UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusPa
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserPasswordAndRecoveryKeyHash = `-- name: UpdateUserPasswordAndRecoveryKeyHash :exec
|
||||
UPDATE user
|
||||
SET password_hash = ?, recovery_key_hash = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateUserPasswordAndRecoveryKeyHashParams struct {
|
||||
PasswordHash string `json:"password_hash"`
|
||||
RecoveryKeyHash string `json:"recovery_key_hash"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserPasswordAndRecoveryKeyHash, arg.PasswordHash, arg.RecoveryKeyHash, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertAnime = `-- name: UpsertAnime :one
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
|
||||
@@ -3,18 +3,14 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"mal/internal/database"
|
||||
@@ -22,10 +18,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserExists = errors.New("username already exists")
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
ErrInvalidPassword = errors.New("password does not meet security requirements")
|
||||
ErrInvalidRecoveryKey = errors.New("invalid recovery details")
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
@@ -50,171 +43,6 @@ func generateSessionToken() (string, error) {
|
||||
return generateToken(32)
|
||||
}
|
||||
|
||||
func generateRecoveryKey() (string, error) {
|
||||
return generateToken(24)
|
||||
}
|
||||
|
||||
func hashRecoveryKey(recoveryKey string) string {
|
||||
sum := sha256.Sum256([]byte(recoveryKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 12 {
|
||||
return fmt.Errorf("password must be at least 12 characters long")
|
||||
}
|
||||
|
||||
var hasUpper, hasLower, hasNumber, hasSpecial bool
|
||||
for _, c := range password {
|
||||
switch {
|
||||
case unicode.IsNumber(c):
|
||||
hasNumber = true
|
||||
case unicode.IsUpper(c):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(c):
|
||||
hasLower = true
|
||||
case unicode.IsPunct(c) || unicode.IsSymbol(c) || !unicode.IsLetter(c) && !unicode.IsNumber(c):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper || !hasLower || !hasNumber || !hasSpecial {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter, one lowercase letter, one number, and one special character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegisterUser(ctx context.Context, username, password string) (*database.User, string, error) {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrInvalidPassword, err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
recoveryKey, err := generateRecoveryKey()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate recovery key: %w", err)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
user, err := s.db.CreateUser(ctx, database.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
RecoveryKeyHash: hashRecoveryKey(recoveryKey),
|
||||
})
|
||||
if err != nil {
|
||||
// Assuming unique constraint failure for username
|
||||
return nil, "", ErrUserExists
|
||||
}
|
||||
|
||||
return &user, recoveryKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) RecoverAccount(ctx context.Context, username, recoveryKey, newPassword string) (string, error) {
|
||||
if err := ValidatePassword(newPassword); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidPassword, err)
|
||||
}
|
||||
|
||||
user, err := s.db.GetUserByUsernameAndRecoveryKeyHash(ctx, database.GetUserByUsernameAndRecoveryKeyHashParams{
|
||||
Username: username,
|
||||
RecoveryKeyHash: hashRecoveryKey(recoveryKey),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrInvalidRecoveryKey
|
||||
}
|
||||
return "", fmt.Errorf("failed to lookup user for recovery: %w", err)
|
||||
}
|
||||
|
||||
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
newRecoveryKey, err := generateRecoveryKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new recovery key: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
|
||||
ID: user.ID,
|
||||
PasswordHash: string(newPasswordHash),
|
||||
RecoveryKeyHash: hashRecoveryKey(newRecoveryKey),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to update recovered account: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.DeleteUserSessions(ctx, user.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to clear existing sessions: %w", err)
|
||||
}
|
||||
|
||||
return newRecoveryKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
|
||||
if err := ValidatePassword(newPassword); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidPassword, err)
|
||||
}
|
||||
|
||||
user, err := s.db.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to lookup user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
|
||||
ID: user.ID,
|
||||
PasswordHash: string(newPasswordHash),
|
||||
RecoveryKeyHash: user.RecoveryKeyHash,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RegenerateRecoveryKey(ctx context.Context, userID, password string) (string, error) {
|
||||
user, err := s.db.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to lookup user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
newRecoveryKey, err := generateRecoveryKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate new recovery key: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.UpdateUserPasswordAndRecoveryKeyHash(ctx, database.UpdateUserPasswordAndRecoveryKeyHashParams{
|
||||
ID: user.ID,
|
||||
PasswordHash: user.PasswordHash,
|
||||
RecoveryKeyHash: hashRecoveryKey(newRecoveryKey),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to rotate recovery key: %w", err)
|
||||
}
|
||||
|
||||
return newRecoveryKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (*database.Session, error) {
|
||||
user, err := s.db.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
@@ -246,10 +74,6 @@ func (s *Service) Login(ctx context.Context, username, password string) (*databa
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string) error {
|
||||
return s.db.DeleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*database.User, error) {
|
||||
session, err := s.db.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
@@ -284,16 +108,3 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string, expiresAt time.Ti
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSessionCookie(w http.ResponseWriter) {
|
||||
isProd := os.Getenv("ENV") == "production"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
Secure: isProd,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Path: "/",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,17 +51,6 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_id")
|
||||
if err == nil {
|
||||
_ = h.authService.Logout(r.Context(), cookie.Value)
|
||||
}
|
||||
|
||||
ClearSessionCookie(w)
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
templates.Login(rateLimitErrorFromQuery(r), "").Render(r.Context(), w)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,6 @@ func NewRouter(cfg Config) http.Handler {
|
||||
middleware.RateLimitAuth(middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogin))).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogout)).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
// Watchlist Endpoints
|
||||
mux.HandleFunc("/api/watchlist/export", watchlistHandler.HandleExportWatchlist)
|
||||
|
||||
@@ -28,8 +28,6 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler {
|
||||
user, err := authService.ValidateSession(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// Invalid session, proceed as unauthenticated
|
||||
// Might also want to clear the invalid cookie here
|
||||
auth.ClearSessionCookie(w)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE user
|
||||
ADD COLUMN recovery_key_hash TEXT NOT NULL DEFAULT '';
|
||||
22
migrations/012_remove_recovery_key.sql
Normal file
22
migrations/012_remove_recovery_key.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
CREATE TABLE user_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO user_new (id, username, password_hash, created_at)
|
||||
SELECT id, username, password_hash, created_at
|
||||
FROM user;
|
||||
|
||||
DROP TABLE user;
|
||||
|
||||
ALTER TABLE user_new RENAME TO user;
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
Reference in New Issue
Block a user