Skip to content

Commit 157dd0e

Browse files
authored
feat(core): Auth Sessions, Farewell Derivation, and Message Scheduling
feat(core): Auth Sessions, Farewell Derivation, and Message Scheduling
2 parents 1606690 + c347664 commit 157dd0e

33 files changed

Lines changed: 1691 additions & 162 deletions

backend/cmd/server/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func main() {
6666

6767
if err := database.DB.AutoMigrate(
6868
&models.User{},
69+
&models.RefreshSession{},
6970
&models.Message{},
7071
&models.MessageReminder{},
7172
&models.Settings{},
@@ -97,6 +98,9 @@ func main() {
9798

9899
database.DB.Exec("UPDATE messages SET encrypted_content = '' WHERE encrypted_content IS NULL;")
99100
database.DB.Exec("UPDATE settings SET webhook_enabled = 0 WHERE webhook_enabled IS NULL;")
101+
database.DB.Exec("UPDATE farewell_letters SET encrypted_content_raw = encrypted_content WHERE encrypted_content_raw IS NULL OR encrypted_content_raw = '';")
102+
database.DB.Exec("UPDATE farewell_letters SET encrypted_rendered_html = '' WHERE encrypted_rendered_html IS NULL;")
103+
database.DB.Exec("UPDATE farewell_letters SET derivatives_pending = 1 WHERE derivatives_pending IS NULL;")
100104

101105
if err := services.EnsureUploadsDir(cfg.Database.Path); err != nil {
102106
log.Fatal("Failed to create uploads directory: ", err)
@@ -113,6 +117,7 @@ func main() {
113117
appSettingsSvc := services.ApplicationSettingsService{}
114118
webhookStore := services.NewWebhookStore(cfg)
115119
userAdminSvc := services.NewUserAdminService(cfg)
120+
farewellDerivationSvc := services.NewFarewellDerivationService()
116121

117122
// --- Wire handlers ---
118123
authH := handlers.NewAuthHandlers(authSvc, cfg)
@@ -125,7 +130,7 @@ func main() {
125130
usersH := handlers.NewUserHandlers(userAdminSvc)
126131

127132
// --- Wire worker ---
128-
w := worker.New(settingsSvc, webhookStore, fileSvc, cfg)
133+
w := worker.New(settingsSvc, webhookStore, fileSvc, farewellDerivationSvc, cfg)
129134

130135
app := fiber.New(fiber.Config{
131136
BodyLimit: 25 * 1024 * 1024,
@@ -193,6 +198,7 @@ func main() {
193198
apiV2.Post("/auth/login", middleware.AuthRateLimiter, authH.LoginV2)
194199
apiV2.Post("/auth/reset-password", middleware.AuthRateLimiter, authH.ResetMasterPasswordV2)
195200
apiV2.Get("/auth/session", authH.SessionStatusV2)
201+
apiV2.Post("/auth/refresh", middleware.AuthRateLimiter, authH.RefreshV2)
196202
apiV2.Post("/auth/logout", authH.LogoutV2)
197203

198204
// Protected routes

backend/internal/config/common/defaults.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ const (
44
DefaultDatabasePath = "./data/aeterna.db"
55
DefaultAllowedOrigins = "http://localhost:5173"
66
DefaultWorkerBaseURL = "http://localhost:5173"
7-
DefaultSessionTTLHours = 12
7+
DefaultSessionTTLHours = 168
8+
DefaultRefreshTTLHours = 720
89
DefaultLogMaxSize = 50
910
DefaultLogMaxBackups = 5
1011
DefaultLogMaxAge = 14

backend/internal/config/config_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ func TestLoad_DevelopmentMode(t *testing.T) {
7777
t.Setenv("DATABASE_PATH", "")
7878
t.Setenv("ALLOWED_ORIGINS", "")
7979
t.Setenv("PROXY_MODE", "")
80+
t.Setenv("AUTH_SESSION_TTL_HOURS", "")
81+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "")
82+
t.Setenv("BASE_URL", "")
8083

8184
cfg := Load()
8285

@@ -89,6 +92,9 @@ func TestLoad_DevelopmentMode(t *testing.T) {
8992
if cfg.Auth.SessionTTLHours != common.DefaultSessionTTLHours {
9093
t.Fatalf("Auth.SessionTTLHours = %d, want %d", cfg.Auth.SessionTTLHours, common.DefaultSessionTTLHours)
9194
}
95+
if cfg.Auth.RefreshTTLHours != common.DefaultRefreshTTLHours {
96+
t.Fatalf("Auth.RefreshTTLHours = %d, want %d", cfg.Auth.RefreshTTLHours, common.DefaultRefreshTTLHours)
97+
}
9298
if cfg.Worker.BaseURL != common.DefaultWorkerBaseURL {
9399
t.Fatalf("Worker.BaseURL = %q, want %q", cfg.Worker.BaseURL, common.DefaultWorkerBaseURL)
94100
}

backend/internal/config/services/auth_service.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func init() {
2020

2121
type AuthSection struct {
2222
SessionTTLHours int
23+
RefreshTTLHours int
2324
AllowRegistration bool
2425
MasterPassword string
2526
CookieSecureMode string
@@ -35,6 +36,7 @@ func (AuthModule) LoadAndValidate() (AuthSection, error) {
3536

3637
return AuthSection{
3738
SessionTTLHours: common.GetPositiveInt("AUTH_SESSION_TTL_HOURS", common.DefaultSessionTTLHours),
39+
RefreshTTLHours: common.GetPositiveInt("AUTH_REFRESH_TTL_HOURS", common.DefaultRefreshTTLHours),
3840
AllowRegistration: os.Getenv("ALLOW_REGISTRATION") == "true",
3941
MasterPassword: os.Getenv("MASTER_PASSWORD"),
4042
CookieSecureMode: cookieMode,

backend/internal/config/services/auth_service_test.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func TestAuthModule_Metadata(t *testing.T) {
1919
func TestAuthModule_LoadAndValidate(t *testing.T) {
2020
t.Run("defaults when no env vars set", func(t *testing.T) {
2121
t.Setenv("AUTH_SESSION_TTL_HOURS", "")
22+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "")
2223
t.Setenv("ALLOW_REGISTRATION", "")
2324
t.Setenv("MASTER_PASSWORD", "")
2425
t.Setenv("AUTH_COOKIE_SECURE_MODE", "")
@@ -29,6 +30,9 @@ func TestAuthModule_LoadAndValidate(t *testing.T) {
2930
if section.SessionTTLHours != common.DefaultSessionTTLHours {
3031
t.Fatalf("SessionTTLHours = %d, want %d", section.SessionTTLHours, common.DefaultSessionTTLHours)
3132
}
33+
if section.RefreshTTLHours != common.DefaultRefreshTTLHours {
34+
t.Fatalf("RefreshTTLHours = %d, want %d", section.RefreshTTLHours, common.DefaultRefreshTTLHours)
35+
}
3236
if section.AllowRegistration {
3337
t.Fatal("AllowRegistration should default to false")
3438
}
@@ -42,46 +46,62 @@ func TestAuthModule_LoadAndValidate(t *testing.T) {
4246

4347
t.Run("custom session TTL", func(t *testing.T) {
4448
t.Setenv("AUTH_SESSION_TTL_HOURS", "48")
49+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "1440")
4550
section, err := AuthModule{}.LoadAndValidate()
4651
if err != nil {
4752
t.Fatalf("unexpected error: %v", err)
4853
}
4954
if section.SessionTTLHours != 48 {
5055
t.Fatalf("SessionTTLHours = %d, want 48", section.SessionTTLHours)
5156
}
57+
if section.RefreshTTLHours != 1440 {
58+
t.Fatalf("RefreshTTLHours = %d, want 1440", section.RefreshTTLHours)
59+
}
5260
})
5361

5462
t.Run("zero TTL falls back to default", func(t *testing.T) {
5563
t.Setenv("AUTH_SESSION_TTL_HOURS", "0")
64+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "0")
5665
section, err := AuthModule{}.LoadAndValidate()
5766
if err != nil {
5867
t.Fatalf("unexpected error: %v", err)
5968
}
6069
if section.SessionTTLHours != common.DefaultSessionTTLHours {
6170
t.Fatalf("SessionTTLHours = %d, want default %d", section.SessionTTLHours, common.DefaultSessionTTLHours)
6271
}
72+
if section.RefreshTTLHours != common.DefaultRefreshTTLHours {
73+
t.Fatalf("RefreshTTLHours = %d, want default %d", section.RefreshTTLHours, common.DefaultRefreshTTLHours)
74+
}
6375
})
6476

6577
t.Run("negative TTL falls back to default", func(t *testing.T) {
6678
t.Setenv("AUTH_SESSION_TTL_HOURS", "-1")
79+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "-1")
6780
section, err := AuthModule{}.LoadAndValidate()
6881
if err != nil {
6982
t.Fatalf("unexpected error: %v", err)
7083
}
7184
if section.SessionTTLHours != common.DefaultSessionTTLHours {
7285
t.Fatalf("SessionTTLHours = %d, want default %d", section.SessionTTLHours, common.DefaultSessionTTLHours)
7386
}
87+
if section.RefreshTTLHours != common.DefaultRefreshTTLHours {
88+
t.Fatalf("RefreshTTLHours = %d, want default %d", section.RefreshTTLHours, common.DefaultRefreshTTLHours)
89+
}
7490
})
7591

7692
t.Run("invalid TTL string falls back to default", func(t *testing.T) {
7793
t.Setenv("AUTH_SESSION_TTL_HOURS", "not-a-number")
94+
t.Setenv("AUTH_REFRESH_TTL_HOURS", "not-a-number")
7895
section, err := AuthModule{}.LoadAndValidate()
7996
if err != nil {
8097
t.Fatalf("unexpected error: %v", err)
8198
}
8299
if section.SessionTTLHours != common.DefaultSessionTTLHours {
83100
t.Fatalf("SessionTTLHours = %d, want default %d", section.SessionTTLHours, common.DefaultSessionTTLHours)
84101
}
102+
if section.RefreshTTLHours != common.DefaultRefreshTTLHours {
103+
t.Fatalf("RefreshTTLHours = %d, want default %d", section.RefreshTTLHours, common.DefaultRefreshTTLHours)
104+
}
85105
})
86106

87107
t.Run("ALLOW_REGISTRATION true enables registration", func(t *testing.T) {
@@ -118,9 +138,9 @@ func TestAuthModule_LoadAndValidate(t *testing.T) {
118138
})
119139

120140
cookieModeTests := []struct {
121-
name string
122-
input string
123-
wantMode string
141+
name string
142+
input string
143+
wantMode string
124144
}{
125145
{"always mode", "always", "always"},
126146
{"never mode", "never", "never"},

backend/internal/handlers/auth.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"strings"
45
"time"
56

67
"github.com/alpyxn/aeterna/backend/internal/config"
@@ -27,6 +28,10 @@ type resetPasswordRequest struct {
2728
NewPassword string `json:"new_password"`
2829
}
2930

31+
type refreshRequest struct {
32+
RefreshToken string `json:"refresh_token"`
33+
}
34+
3035
type sessionMode int
3136

3237
const (
@@ -184,12 +189,38 @@ func (h *AuthHandlers) SessionStatusV2(c *fiber.Ctx) error {
184189
return c.JSON(fiber.Map{"authorized": true, "user_id": userID})
185190
}
186191

192+
func (h *AuthHandlers) RefreshV2(c *fiber.Ctx) error {
193+
var req refreshRequest
194+
if len(c.Body()) > 0 {
195+
if err := c.BodyParser(&req); err != nil {
196+
return writeError(c, services.BadRequest("Invalid request body", err))
197+
}
198+
}
199+
if strings.TrimSpace(req.RefreshToken) == "" {
200+
return writeError(c, services.NewAPIError(401, "unauthorized", "Invalid refresh token.", nil))
201+
}
202+
userID, accessToken, accessExp, nextRefreshToken, nextRefreshExp, err := h.auth.RefreshSessionPair(req.RefreshToken)
203+
if err != nil {
204+
return writeError(c, err)
205+
}
206+
return c.JSON(bearerSessionPayload(userID, accessToken, accessExp, nextRefreshToken, nextRefreshExp))
207+
}
208+
187209
func (h *AuthHandlers) Logout(c *fiber.Ctx) error {
188210
h.clearSessionCookie(c)
189211
return c.JSON(fiber.Map{"success": true})
190212
}
191213

192214
func (h *AuthHandlers) LogoutV2(c *fiber.Ctx) error {
215+
var req refreshRequest
216+
if len(c.Body()) > 0 {
217+
if err := c.BodyParser(&req); err != nil {
218+
return writeError(c, services.BadRequest("Invalid request body", err))
219+
}
220+
}
221+
if revokeErr := h.auth.RevokeRefreshToken(req.RefreshToken); revokeErr != nil {
222+
return writeError(c, revokeErr)
223+
}
193224
h.clearSessionCookie(c)
194225
return c.JSON(fiber.Map{"success": true})
195226
}
@@ -240,17 +271,23 @@ func (h *AuthHandlers) sessionPayload(c *fiber.Ctx, userID string, mode sessionM
240271
}
241272

242273
func (h *AuthHandlers) issueSessionPayload(userID string) (fiber.Map, error) {
243-
token, exp, err := h.auth.IssueSessionToken(userID)
274+
accessToken, accessExp, refreshToken, refreshExp, err := h.auth.IssueSessionPair(userID)
244275
if err != nil {
245276
return nil, err
246277
}
278+
return bearerSessionPayload(userID, accessToken, accessExp, refreshToken, refreshExp), nil
279+
}
280+
281+
func bearerSessionPayload(userID, accessToken string, accessExp time.Time, refreshToken string, refreshExp time.Time) fiber.Map {
247282
return fiber.Map{
248-
"success": true,
249-
"user_id": userID,
250-
"token_type": "Bearer",
251-
"access_token": token,
252-
"expires_at": exp.UTC().Format(time.RFC3339),
253-
}, nil
283+
"success": true,
284+
"user_id": userID,
285+
"token_type": "Bearer",
286+
"access_token": accessToken,
287+
"expires_at": accessExp.UTC().Format(time.RFC3339),
288+
"refresh_token": refreshToken,
289+
"refresh_expires_at": refreshExp.UTC().Format(time.RFC3339),
290+
}
254291
}
255292

256293
func (h *AuthHandlers) issueSessionCookie(c *fiber.Ctx, userID string) error {

0 commit comments

Comments
 (0)