remove dumb auth
This commit is contained in:
@@ -24,6 +24,9 @@ Regenerate:
|
|||||||
- `go generate ./api/v1/generate.go`
|
- `go generate ./api/v1/generate.go`
|
||||||
- `cd frontend && bun run generate:api`
|
- `cd frontend && bun run generate:api`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
|
||||||
|
|
||||||
Examples of generated files:
|
Examples of generated files:
|
||||||
- `api/v1/api.gen.go`
|
- `api/v1/api.gen.go`
|
||||||
- `frontend/src/generated/**/*.ts`
|
- `frontend/src/generated/**/*.ts`
|
||||||
|
|||||||
@@ -2031,13 +2031,21 @@ type LoginResponseObject interface {
|
|||||||
VisitLoginResponse(w http.ResponseWriter) error
|
VisitLoginResponse(w http.ResponseWriter) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login200JSONResponse LoginResponse
|
type Login200ResponseHeaders struct {
|
||||||
|
SetCookie string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Login200JSONResponse struct {
|
||||||
|
Body LoginResponse
|
||||||
|
Headers Login200ResponseHeaders
|
||||||
|
}
|
||||||
|
|
||||||
func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
|
func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
|
|
||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login400JSONResponse ErrorResponse
|
type Login400JSONResponse ErrorResponse
|
||||||
@@ -2124,13 +2132,21 @@ type RegisterResponseObject interface {
|
|||||||
VisitRegisterResponse(w http.ResponseWriter) error
|
VisitRegisterResponse(w http.ResponseWriter) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Register201JSONResponse LoginResponse
|
type Register201ResponseHeaders struct {
|
||||||
|
SetCookie string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Register201JSONResponse struct {
|
||||||
|
Body LoginResponse
|
||||||
|
Headers Register201ResponseHeaders
|
||||||
|
}
|
||||||
|
|
||||||
func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
|
||||||
w.WriteHeader(201)
|
w.WriteHeader(201)
|
||||||
|
|
||||||
return json.NewEncoder(w).Encode(response)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Register400JSONResponse ErrorResponse
|
type Register400JSONResponse ErrorResponse
|
||||||
|
|||||||
@@ -41,8 +41,13 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Login200JSONResponse{
|
return Login200JSONResponse{
|
||||||
Username: user.ID,
|
Body: LoginResponse{
|
||||||
IsAdmin: user.Admin,
|
Username: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
},
|
||||||
|
Headers: Login200ResponseHeaders{
|
||||||
|
SetCookie: s.getSetCookieFromContext(ctx),
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +86,13 @@ func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (R
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Register201JSONResponse{
|
return Register201JSONResponse{
|
||||||
Username: user.ID,
|
Body: LoginResponse{
|
||||||
IsAdmin: user.Admin,
|
Username: user.ID,
|
||||||
|
IsAdmin: user.Admin,
|
||||||
|
},
|
||||||
|
Headers: Register201ResponseHeaders{
|
||||||
|
SetCookie: s.getSetCookieFromContext(ctx),
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +217,14 @@ func (s *Server) getResponseWriterFromContext(ctx context.Context) http.Response
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSetCookieFromContext(ctx context.Context) string {
|
||||||
|
w := s.getResponseWriterFromContext(ctx)
|
||||||
|
if w == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return w.Header().Get("Set-Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
// getSession retrieves auth data from the session cookie
|
// getSession retrieves auth data from the session cookie
|
||||||
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
|
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
|
||||||
// Get session from cookie store
|
// Get session from cookie store
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ func (suite *AuthTestSuite) createTestUser(username, password string) {
|
|||||||
suite.Require().NoError(err)
|
suite.Require().NoError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *AuthTestSuite) assertSessionCookie(cookie *http.Cookie) {
|
||||||
|
suite.Require().NotNil(cookie)
|
||||||
|
suite.Equal("token", cookie.Name)
|
||||||
|
suite.NotEmpty(cookie.Value)
|
||||||
|
suite.True(cookie.HttpOnly)
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
||||||
reqBody := LoginRequest{
|
reqBody := LoginRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -86,6 +93,7 @@ func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
|
|||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
suite.Require().Len(cookies, 1, "should have session cookie")
|
suite.Require().Len(cookies, 1, "should have session cookie")
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
|
|
||||||
return cookies[0]
|
return cookies[0]
|
||||||
}
|
}
|
||||||
@@ -109,6 +117,10 @@ func (suite *AuthTestSuite) TestAPILogin() {
|
|||||||
var resp LoginResponse
|
var resp LoginResponse
|
||||||
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||||
suite.Equal("testuser", resp.Username)
|
suite.Equal("testuser", resp.Username)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1)
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
|
||||||
@@ -146,7 +158,8 @@ func (suite *AuthTestSuite) TestAPIRegister() {
|
|||||||
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
suite.Require().NotEmpty(cookies, "register should set a session cookie")
|
suite.Require().Len(cookies, 1, "register should set a session cookie")
|
||||||
|
suite.assertSessionCookie(cookies[0])
|
||||||
|
|
||||||
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
|
||||||
suite.Require().NoError(err)
|
suite.Require().NoError(err)
|
||||||
@@ -182,6 +195,10 @@ func (suite *AuthTestSuite) TestAPILogout() {
|
|||||||
suite.srv.ServeHTTP(w, req)
|
suite.srv.ServeHTTP(w, req)
|
||||||
|
|
||||||
suite.Equal(http.StatusOK, w.Code)
|
suite.Equal(http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
suite.Require().Len(cookies, 1)
|
||||||
|
suite.Equal("token", cookies[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *AuthTestSuite) TestAPIGetMe() {
|
func (suite *AuthTestSuite) TestAPIGetMe() {
|
||||||
|
|||||||
@@ -626,8 +626,9 @@ components:
|
|||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
type: http
|
type: apiKey
|
||||||
scheme: bearer
|
in: cookie
|
||||||
|
name: token
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/documents:
|
/documents:
|
||||||
@@ -1174,6 +1175,11 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: Successful login
|
description: Successful login
|
||||||
|
headers:
|
||||||
|
Set-Cookie:
|
||||||
|
description: HttpOnly session cookie for authenticated requests.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -1212,6 +1218,11 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
201:
|
201:
|
||||||
description: Successful registration
|
description: Successful registration
|
||||||
|
headers:
|
||||||
|
Set-Cookie:
|
||||||
|
description: HttpOnly session cookie for authenticated requests.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -1,115 +1,11 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { setupAuthInterceptors, TOKEN_KEY } from './authInterceptor';
|
import { setupAuthInterceptors } from './authInterceptor';
|
||||||
|
|
||||||
type RequestConfig = {
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResponseValue = {
|
|
||||||
status?: number;
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResponseError = {
|
|
||||||
response?: {
|
|
||||||
status?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function createMockAxiosInstance() {
|
|
||||||
let nextRequestId = 1;
|
|
||||||
let nextResponseId = 1;
|
|
||||||
|
|
||||||
const requestHandlers = new Map<
|
|
||||||
number,
|
|
||||||
[(config: RequestConfig) => RequestConfig, (error: unknown) => Promise<never>]
|
|
||||||
>();
|
|
||||||
const responseHandlers = new Map<
|
|
||||||
number,
|
|
||||||
[(response: ResponseValue) => ResponseValue, (error: ResponseError) => Promise<never>]
|
|
||||||
>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
interceptors: {
|
|
||||||
request: {
|
|
||||||
use: vi.fn((fulfilled, rejected) => {
|
|
||||||
const id = nextRequestId++;
|
|
||||||
requestHandlers.set(id, [fulfilled, rejected]);
|
|
||||||
return id;
|
|
||||||
}),
|
|
||||||
eject: vi.fn((id: number) => {
|
|
||||||
requestHandlers.delete(id);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
response: {
|
|
||||||
use: vi.fn((fulfilled, rejected) => {
|
|
||||||
const id = nextResponseId++;
|
|
||||||
responseHandlers.set(id, [fulfilled, rejected]);
|
|
||||||
return id;
|
|
||||||
}),
|
|
||||||
eject: vi.fn((id: number) => {
|
|
||||||
responseHandlers.delete(id);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getRequestHandler(id = 1) {
|
|
||||||
return requestHandlers.get(id);
|
|
||||||
},
|
|
||||||
getResponseHandler(id = 1) {
|
|
||||||
return responseHandlers.get(id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('setupAuthInterceptors', () => {
|
describe('setupAuthInterceptors', () => {
|
||||||
beforeEach(() => {
|
it('is a no-op when auth is handled by HttpOnly cookies', () => {
|
||||||
localStorage.clear();
|
const cleanup = setupAuthInterceptors();
|
||||||
});
|
|
||||||
|
|
||||||
it('registers request and response interceptors and adds the auth header when a token exists', () => {
|
expect(typeof cleanup).toBe('function');
|
||||||
const axiosInstance = createMockAxiosInstance();
|
expect(() => cleanup()).not.toThrow();
|
||||||
|
|
||||||
setupAuthInterceptors(axiosInstance as never);
|
|
||||||
|
|
||||||
expect(axiosInstance.interceptors.request.use).toHaveBeenCalledTimes(1);
|
|
||||||
expect(axiosInstance.interceptors.response.use).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, 'token-123');
|
|
||||||
|
|
||||||
const requestHandler = axiosInstance.getRequestHandler()?.[0];
|
|
||||||
const config: { headers: Record<string, string> } = { headers: {} };
|
|
||||||
const nextConfig = requestHandler?.(config);
|
|
||||||
|
|
||||||
expect(nextConfig).toBe(config);
|
|
||||||
expect(config.headers.Authorization).toBe('Bearer token-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears the auth token on 401 responses', async () => {
|
|
||||||
const axiosInstance = createMockAxiosInstance();
|
|
||||||
setupAuthInterceptors(axiosInstance as never);
|
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, 'token-123');
|
|
||||||
|
|
||||||
const responseErrorHandler = axiosInstance.getResponseHandler()?.[1];
|
|
||||||
|
|
||||||
await expect(responseErrorHandler?.({ response: { status: 401 } })).rejects.toEqual({
|
|
||||||
response: { status: 401 },
|
|
||||||
});
|
|
||||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ejects previous interceptors before installing a new set', () => {
|
|
||||||
const firstInstance = createMockAxiosInstance();
|
|
||||||
const secondInstance = createMockAxiosInstance();
|
|
||||||
|
|
||||||
const cleanup = setupAuthInterceptors(firstInstance as never);
|
|
||||||
setupAuthInterceptors(secondInstance as never);
|
|
||||||
|
|
||||||
expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1);
|
|
||||||
expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1);
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1);
|
|
||||||
expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,46 +1,3 @@
|
|||||||
import axios, { type AxiosInstance } from 'axios';
|
export function setupAuthInterceptors() {
|
||||||
|
return () => {};
|
||||||
const TOKEN_KEY = 'antholume_token';
|
|
||||||
|
|
||||||
let interceptorCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
export function setupAuthInterceptors(axiosInstance: AxiosInstance = axios) {
|
|
||||||
if (interceptorCleanup) {
|
|
||||||
interceptorCleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestInterceptorId = axiosInstance.interceptors.request.use(
|
|
||||||
config => {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
|
||||||
if (token && config.headers) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const responseInterceptorId = axiosInstance.interceptors.response.use(
|
|
||||||
response => {
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
interceptorCleanup = () => {
|
|
||||||
axiosInstance.interceptors.request.eject(requestInterceptorId);
|
|
||||||
axiosInstance.interceptors.response.eject(responseInterceptorId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return interceptorCleanup;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TOKEN_KEY };
|
|
||||||
export default axios;
|
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
|
||||||
import { ToastProvider } from './components/ToastContext';
|
import { ToastProvider } from './components/ToastContext';
|
||||||
import { setupAuthInterceptors } from './auth/authInterceptor';
|
|
||||||
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
setupAuthInterceptors(axios);
|
|
||||||
initializeThemeMode();
|
initializeThemeMode();
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
Reference in New Issue
Block a user