This commit is contained in:
16 files changed
+672
-57
No files matched your search
@@ -30,6 +30,11 @@ type LoginRequest struct {
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// DeleteAccountRequest re-confirms the password before an account is deleted.
|
||||
type DeleteAccountRequest struct {
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// AuthResponse contains JWT token and user information
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
@@ -269,3 +274,51 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"message": "logged out"})
|
||||
}
|
||||
|
||||
// DeleteAccountHandler deletes the caller's account after re-checking the
|
||||
// password. The caller's games lose the claim and the name; see
|
||||
// Service.DeleteAccount.
|
||||
func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error {
|
||||
userID, _ := c.Locals("userID").(string)
|
||||
sessionID, _ := c.Locals("sessionID").(string)
|
||||
if userID == "" || sessionID == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "unauthorized",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
var req DeleteAccountRequest
|
||||
if err := c.BodyParser(&req); err != nil || req.Password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "password is required to delete the account",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.svc.DeleteAccount(userID, sessionID, req.Password); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidCredentials):
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
case errors.Is(err, service.ErrAuthBusy):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
|
||||
})
|
||||
case errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "account storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
default:
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to delete account",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -145,6 +145,22 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
|
||||
// Logout
|
||||
auth.Post("/logout", AuthRequired(validateToken), h.LogoutHandler)
|
||||
|
||||
// Delete own account: 5 req/min per IP. It takes a password, so it is
|
||||
// limited like login; authentication runs first, so only a signed-in
|
||||
// caller reaches the password check.
|
||||
auth.Delete("/me", AuthRequired(validateToken), limiter.New(limiter.Config{
|
||||
Max: 5,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: "5 account deletion attempts per minute allowed",
|
||||
})
|
||||
},
|
||||
}), h.DeleteAccountHandler)
|
||||
|
||||
// Game routes with standard rate limiting
|
||||
maxReq := rateLimitRate
|
||||
if devMode {
|
||||
|
||||
@@ -88,6 +88,7 @@ func TestAPIRoutes(t *testing.T) {
|
||||
"POST /api/auth/login",
|
||||
"GET /api/auth/me",
|
||||
"POST /api/auth/logout",
|
||||
"DELETE /api/auth/me",
|
||||
"POST /api/games",
|
||||
"GET /api/games/:gameId",
|
||||
"GET /api/games/:gameId/history",
|
||||
@@ -212,3 +213,74 @@ func TestStoredGameEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountRequiresPasswordAndEndsTheSession(t *testing.T) {
|
||||
store, err := storage.NewStore(pgtest.DSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := service.New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { svc.Shutdown(time.Second) })
|
||||
app := NewFiberApp(nil, svc, Options{DevMode: true})
|
||||
|
||||
send := func(method, path, token, body string) int {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
request := httptest.NewRequest(method, path, reader)
|
||||
if body != "" {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
response, err := app.Test(request, 10_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.StatusCode
|
||||
}
|
||||
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, err := svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
token, body string
|
||||
want int
|
||||
}{
|
||||
"no token": {"", `{"password":"Password1"}`, 401},
|
||||
"no password": {token, `{}`, 400},
|
||||
"wrong password": {token, `{"password":"Password2"}`, 401},
|
||||
} {
|
||||
if got := send("DELETE", "/api/auth/me", test.token, test.body); got != test.want {
|
||||
t.Errorf("%s: status %d, want %d", name, got, test.want)
|
||||
}
|
||||
}
|
||||
if got := send("GET", "/api/auth/me", token, ""); got != 200 {
|
||||
t.Fatalf("a refused deletion must leave the account working: /auth/me = %d", got)
|
||||
}
|
||||
|
||||
if got := send("DELETE", "/api/auth/me", token, `{"password":"Password1"}`); got != 204 {
|
||||
t.Fatalf("delete: status %d, want 204", got)
|
||||
}
|
||||
if got := send("GET", "/api/auth/me", token, ""); got != 401 {
|
||||
t.Errorf("token still accepted after deletion: /auth/me = %d", got)
|
||||
}
|
||||
if _, _, err := svc.AuthenticateUser("alice", "Password1"); err == nil {
|
||||
t.Error("deleted account can still sign in")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user