From ac3a62ed115f678ad98db7bb823dfc60c2eadbad Mon Sep 17 00:00:00 2001 From: "[CODE] Marco Carstensen" Date: Tue, 24 Mar 2026 13:57:47 +0100 Subject: [PATCH] feat: add personal API tokens for REST API authentication Allow users to create, list, and delete personal API tokens for programmatic access to the REST API. Tokens use Bearer authentication, are stored as SHA-256 hashes, and support optional expiry (max 1 year). Existing session-based auth remains unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/app/app.module.ts | 10 +- frontend/src/app/rest/rest.service.ts | 17 +++ .../create-api-token-dialog.component.html | 44 +++++++ .../create-api-token-dialog.component.scss | 13 ++ .../create-api-token-dialog.component.ts | 65 ++++++++++ frontend/src/app/user/api-token.ts | 42 ++++++ frontend/src/app/user/me/me.component.html | 48 +++++++ frontend/src/app/user/me/me.component.ts | 47 ++++++- frontend/src/assets/i18n/de.json | 25 ++++ frontend/src/assets/i18n/en.json | 25 ++++ .../configuration/ONekoUserDetailsImpl.java | 2 +- .../ApiTokenAuthenticationFilter.java | 98 ++++++++++++++ .../oneko/security/SecurityConfiguration.java | 7 +- .../java/io/oneko/user/auth/ApiToken.java | 26 ++++ .../oneko/user/auth/ApiTokenRepository.java | 18 +++ .../ApiTokenInMemoryRepository.java | 52 ++++++++ .../oneko/user/persistence/ApiTokenMongo.java | 36 ++++++ .../persistence/ApiTokenMongoRepository.java | 76 +++++++++++ .../ApiTokenMongoSpringRepository.java | 16 +++ .../oneko/user/rest/ApiTokenController.java | 106 +++++++++++++++ .../java/io/oneko/user/rest/ApiTokenDTO.java | 45 +++++++ .../io/oneko/user/rest/UserController.java | 6 +- .../ApiTokenAuthenticationFilterTest.java | 27 ++++ .../ApiTokenInMemoryRepositoryTest.java | 121 ++++++++++++++++++ 24 files changed, 967 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.html create mode 100644 frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.scss create mode 100644 frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.ts create mode 100644 frontend/src/app/user/api-token.ts create mode 100644 src/main/java/io/oneko/security/ApiTokenAuthenticationFilter.java create mode 100644 src/main/java/io/oneko/user/auth/ApiToken.java create mode 100644 src/main/java/io/oneko/user/auth/ApiTokenRepository.java create mode 100644 src/main/java/io/oneko/user/persistence/ApiTokenInMemoryRepository.java create mode 100644 src/main/java/io/oneko/user/persistence/ApiTokenMongo.java create mode 100644 src/main/java/io/oneko/user/persistence/ApiTokenMongoRepository.java create mode 100644 src/main/java/io/oneko/user/persistence/ApiTokenMongoSpringRepository.java create mode 100644 src/main/java/io/oneko/user/rest/ApiTokenController.java create mode 100644 src/main/java/io/oneko/user/rest/ApiTokenDTO.java create mode 100644 src/test/java/io/oneko/security/ApiTokenAuthenticationFilterTest.java create mode 100644 src/test/java/io/oneko/user/persistence/ApiTokenInMemoryRepositoryTest.java diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 5deda67a..662edd6b 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -31,6 +31,9 @@ import { MatLegacyTooltipDefaultOptions as MatTooltipDefaultOptions, MatLegacyTooltipModule as MatTooltipModule } from "@angular/material/legacy-tooltip"; +import {ClipboardModule} from "@angular/cdk/clipboard"; +import {MatDatepickerModule} from "@angular/material/datepicker"; +import {MatNativeDateModule} from "@angular/material/core"; import {BrowserModule, DomSanitizer} from '@angular/platform-browser'; import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; @@ -77,6 +80,7 @@ import {AuthGuardService} from "./session/auth-guard.service"; import {AuthInterceptor} from "./session/auth.interceptor"; import {AuthService} from "./session/auth.service"; import {IsAdminOrDoerGuardService} from "./session/is-admin-or-doer-guard.service"; +import {CreateApiTokenDialogComponent} from "./user/api-token-dialog/create-api-token-dialog.component"; import {UserEditDialog} from "./user/edit-dialog/user-edit-dialog.component"; import {UserListComponent} from "./user/list/user-list.component"; import {MeComponent} from "./user/me/me.component"; @@ -196,7 +200,8 @@ export function HttpLoaderFactory(http: HttpClient) { SearchPageComponent, DeploymentUrlsComponent, ContainerLogsComponent, - SafeHtmlPipe + SafeHtmlPipe, + CreateApiTokenDialogComponent ], imports: [ BrowserModule, @@ -235,6 +240,9 @@ export function HttpLoaderFactory(http: HttpClient) { MatStepperModule, MatCheckboxModule, MatRadioModule, + MatDatepickerModule, + MatNativeDateModule, + ClipboardModule, NgxMatSelectSearchModule, NgxsModule.forRoot(appStates, {developmentMode: !environment.production}), NgxsStoragePluginModule.forRoot({key: [ThemingState, I18nState]}), diff --git a/frontend/src/app/rest/rest.service.ts b/frontend/src/app/rest/rest.service.ts index 42fe3ea5..c5dd249d 100644 --- a/frontend/src/app/rest/rest.service.ts +++ b/frontend/src/app/rest/rest.service.ts @@ -3,6 +3,7 @@ import {Injectable} from "@angular/core"; import {Observable} from "rxjs"; import {map, tap} from "rxjs/operators"; import {AuthService} from "../session/auth.service"; +import {ApiToken, ApiTokenDTO, CreateApiTokenRequest, CreateApiTokenResponse} from "../user/api-token"; import {ChangePasswordDTO} from "../user/change-password-dto"; import {User, UserDTO} from "../user/user"; import {ActivityRestService} from "./activity-rest.service"; @@ -101,6 +102,22 @@ export class RestService { .pipe(map(dto => dto.available)); } + /*------------------------------------------------------------ + * API Tokens + ------------------------------------------------------------*/ + + public getApiTokens(username: string): Observable> { + return this.http.get>(`/api/user/${username}/tokens`) + .pipe(map(tokens => tokens.map(ApiToken.from))); + } + + public createApiToken(username: string, request: CreateApiTokenRequest): Observable { + return this.http.post(`/api/user/${username}/tokens`, request); + } + + public deleteApiToken(username: string, tokenId: string): Observable { + return this.http.delete(`/api/user/${username}/tokens/${tokenId}`); + } /*------------------------------------------------------------ * delegates diff --git a/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.html b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.html new file mode 100644 index 00000000..eea18224 --- /dev/null +++ b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.html @@ -0,0 +1,44 @@ +

{{ 'components.user.me.apiTokens.createTitle' | translate }}

+ + +
+ + {{ 'components.user.me.apiTokens.tokenName' | translate }} + + + + + {{ 'components.user.me.apiTokens.expiresAt' | translate }} + + + + {{ 'components.user.me.apiTokens.expiresAtHint' | translate }} + +
+ + +
+
+ + {{ 'components.user.me.apiTokens.tokenWarning' | translate }} +
+ + + {{ 'components.user.me.apiTokens.yourToken' | translate }} + + + +
+
+ + + + + + diff --git a/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.scss b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.scss new file mode 100644 index 00000000..5375d577 --- /dev/null +++ b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.scss @@ -0,0 +1,13 @@ +.token-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-radius: 4px; + background-color: rgba(255, 152, 0, 0.1); + color: #ff9800; + + mat-icon { + flex-shrink: 0; + } +} diff --git a/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.ts b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.ts new file mode 100644 index 00000000..2e4c4c34 --- /dev/null +++ b/frontend/src/app/user/api-token-dialog/create-api-token-dialog.component.ts @@ -0,0 +1,65 @@ +import {Component} from "@angular/core"; +import {MatLegacyDialogRef as MatDialogRef} from "@angular/material/legacy-dialog"; +import {MatLegacySnackBar as MatSnackBar} from "@angular/material/legacy-snack-bar"; +import {Clipboard} from "@angular/cdk/clipboard"; +import {TranslateService} from "@ngx-translate/core"; +import {RestService} from "../../rest/rest.service"; +import {CreateApiTokenResponse} from "../api-token"; + +@Component({ + selector: 'create-api-token-dialog', + templateUrl: './create-api-token-dialog.component.html', + styleUrls: ['./create-api-token-dialog.component.scss'] +}) +export class CreateApiTokenDialogComponent { + + tokenName: string = ''; + expiresAt: Date | null = null; + username: string; + minDate: Date = new Date(); + maxDate: Date = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + + createdToken: CreateApiTokenResponse | null = null; + isCreating: boolean = false; + + constructor(public dialogRef: MatDialogRef, + private rest: RestService, + private snackBar: MatSnackBar, + private clipboard: Clipboard, + private readonly translate: TranslateService) { + } + + createToken(): void { + if (!this.tokenName || this.isCreating) { + return; + } + + this.isCreating = true; + const request = { + name: this.tokenName, + expiresAt: this.expiresAt ? this.expiresAt.toISOString() : null + }; + + this.rest.createApiToken(this.username, request).subscribe({ + next: (response) => { + this.createdToken = response; + this.isCreating = false; + }, + error: () => { + this.isCreating = false; + this.snackBar.open(this.translate.instant('components.user.me.apiTokens.createError'), null, {duration: 3000}); + } + }); + } + + copyToken(): void { + if (this.createdToken) { + this.clipboard.copy(this.createdToken.rawToken); + this.snackBar.open(this.translate.instant('components.user.me.apiTokens.copied'), null, {duration: 2000}); + } + } + + close(): void { + this.dialogRef.close(this.createdToken != null); + } +} diff --git a/frontend/src/app/user/api-token.ts b/frontend/src/app/user/api-token.ts new file mode 100644 index 00000000..9e5cd9b2 --- /dev/null +++ b/frontend/src/app/user/api-token.ts @@ -0,0 +1,42 @@ +export interface ApiTokenDTO { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; +} + +export interface CreateApiTokenRequest { + name: string; + expiresAt: string | null; +} + +export interface CreateApiTokenResponse { + token: ApiTokenDTO; + rawToken: string; +} + +export class ApiToken implements ApiTokenDTO { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + + static from(dto: ApiTokenDTO): ApiToken { + const token = new ApiToken(); + token.id = dto.id; + token.name = dto.name; + token.createdAt = dto.createdAt; + token.expiresAt = dto.expiresAt; + token.lastUsedAt = dto.lastUsedAt; + return token; + } + + isExpired(): boolean { + if (!this.expiresAt) { + return false; + } + return new Date(this.expiresAt) < new Date(); + } +} diff --git a/frontend/src/app/user/me/me.component.html b/frontend/src/app/user/me/me.component.html index 2a351c57..362c3db2 100644 --- a/frontend/src/app/user/me/me.component.html +++ b/frontend/src/app/user/me/me.component.html @@ -32,6 +32,54 @@ {{me.role}} + + +
+
+

{{ 'components.user.me.apiTokens.title' | translate }}

+ +
+ +
+ {{ 'components.user.me.apiTokens.noTokens' | translate }} +
+ + + + + + + + + + + + + + + + + + + + +
{{ 'components.user.me.apiTokens.columnName' | translate }}{{ 'components.user.me.apiTokens.columnCreated' | translate }}{{ 'components.user.me.apiTokens.columnExpires' | translate }}{{ 'components.user.me.apiTokens.columnLastUsed' | translate }}
{{token.name}}{{token.createdAt | date:'medium'}} + {{token.expiresAt | date:'medium'}} + {{ 'components.user.me.apiTokens.never' | translate }} + + {{token.lastUsedAt | date:'medium'}} + {{ 'components.user.me.apiTokens.neverUsed' | translate }} + + +
+
+
diff --git a/frontend/src/app/user/me/me.component.ts b/frontend/src/app/user/me/me.component.ts index e0ac6a6e..f31232ab 100644 --- a/frontend/src/app/user/me/me.component.ts +++ b/frontend/src/app/user/me/me.component.ts @@ -1,10 +1,13 @@ import {Component} from "@angular/core"; import {MatLegacyDialog as MatDialog} from "@angular/material/legacy-dialog"; +import {MatLegacySnackBar as MatSnackBar} from "@angular/material/legacy-snack-bar"; import {Router} from "@angular/router"; import {RestService} from "../../rest/rest.service"; import {AuthService} from "../../session/auth.service"; import {ConfirmDialog, ConfirmDialogData} from "../../util/confirm-dialog/confirm-dialog.component"; +import {CreateApiTokenDialogComponent} from "../api-token-dialog/create-api-token-dialog.component"; +import {ApiToken} from "../api-token"; import {UserEditDialog} from "../edit-dialog/user-edit-dialog.component"; import {User} from "../user"; import {zip} from "rxjs"; @@ -18,13 +21,18 @@ import {TranslateService} from "@ngx-translate/core"; export class MeComponent { public me: User; + public apiTokens: ApiToken[] = []; constructor(private rest: RestService, private dialog: MatDialog, + private snackBar: MatSnackBar, private auth: AuthService, private router: Router, private readonly translate: TranslateService) { - this.rest.currentUser().subscribe(user => this.me = user); + this.rest.currentUser().subscribe(user => { + this.me = user; + this.loadTokens(); + }); } public editAccount() { @@ -57,4 +65,41 @@ export class MeComponent { }); } + public createApiToken() { + const dialogRef = this.dialog.open(CreateApiTokenDialogComponent, { + width: "500px" + }); + dialogRef.componentInstance.username = this.me.username; + dialogRef.afterClosed().subscribe(created => { + if (created) { + this.loadTokens(); + } + }); + } + + public deleteApiToken(token: ApiToken) { + this.dialog.open(ConfirmDialog, { + data: { + title: this.translate.instant('components.user.me.apiTokens.deleteTitle'), + message: this.translate.instant('components.user.me.apiTokens.deleteMessage', {name: token.name}), + okButtonText: this.translate.instant('components.user.me.apiTokens.deleteConfirm') + }, + width: "50%" + }).afterClosed().subscribe(result => { + if (result === true) { + this.rest.deleteApiToken(this.me.username, token.id).subscribe(() => { + this.loadTokens(); + this.snackBar.open(this.translate.instant('components.user.me.apiTokens.deleted'), null, {duration: 2000}); + }); + } + }); + } + + private loadTokens() { + if (this.me) { + this.rest.getApiTokens(this.me.username).subscribe(tokens => { + this.apiTokens = tokens; + }); + } + } } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 8ddcdec3..dce83996 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -387,6 +387,31 @@ "title": "Soll Ihr Account gelöscht werden?", "message": "Bitte bestätigen Sie das Löschen Ihres Accounts. Diese Aktion kann nicht rückgängig gemacht werden.", "okButtonText": "Löschen" + }, + "apiTokens": { + "title": "API-Tokens", + "create": "Token erstellen", + "createTitle": "API-Token erstellen", + "tokenName": "Token-Name", + "expiresAt": "Gültig bis", + "expiresAtHint": "Leer lassen für unbegrenzte Gültigkeit", + "noTokens": "Noch keine API-Tokens erstellt.", + "columnName": "Name", + "columnCreated": "Erstellt", + "columnExpires": "Gültig bis", + "columnLastUsed": "Zuletzt verwendet", + "never": "Nie", + "neverUsed": "Nie", + "delete": "Löschen", + "deleteTitle": "API-Token löschen?", + "deleteMessage": "Sind Sie sicher, dass Sie den Token \"{{name}}\" löschen möchten? Alle Anwendungen, die diesen Token verwenden, verlieren den Zugriff.", + "deleteConfirm": "Löschen", + "deleted": "Token gelöscht", + "yourToken": "Ihr API-Token", + "tokenWarning": "Kopieren Sie den Token jetzt. Er wird nicht erneut angezeigt!", + "copy": "In Zwischenablage kopieren", + "copied": "Token in Zwischenablage kopiert", + "createError": "Token konnte nicht erstellt werden" } } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index fec6c0a4..df7b53c7 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -387,6 +387,31 @@ "title": "Delete your account?", "message": "Please confirm the deletion of your account. This action cannot be undone.", "okButtonText": "Delete" + }, + "apiTokens": { + "title": "API Tokens", + "create": "Create Token", + "createTitle": "Create API Token", + "tokenName": "Token Name", + "expiresAt": "Expires at", + "expiresAtHint": "Leave empty for no expiration", + "noTokens": "No API tokens created yet.", + "columnName": "Name", + "columnCreated": "Created", + "columnExpires": "Expires", + "columnLastUsed": "Last Used", + "never": "Never", + "neverUsed": "Never", + "delete": "Delete", + "deleteTitle": "Delete API Token?", + "deleteMessage": "Are you sure you want to delete the token \"{{name}}\"? Any applications using this token will lose access.", + "deleteConfirm": "Delete", + "deleted": "Token deleted", + "yourToken": "Your API Token", + "tokenWarning": "Make sure to copy your token now. You won't be able to see it again!", + "copy": "Copy to clipboard", + "copied": "Token copied to clipboard", + "createError": "Failed to create token" } } } diff --git a/src/main/java/io/oneko/configuration/ONekoUserDetailsImpl.java b/src/main/java/io/oneko/configuration/ONekoUserDetailsImpl.java index 18f157d1..c9c077f7 100644 --- a/src/main/java/io/oneko/configuration/ONekoUserDetailsImpl.java +++ b/src/main/java/io/oneko/configuration/ONekoUserDetailsImpl.java @@ -9,7 +9,7 @@ import java.util.Collection; import java.util.Collections; -class ONekoUserDetailsImpl implements ONekoUserDetails { +public class ONekoUserDetailsImpl implements ONekoUserDetails { private static final String DEFAULT_ROLE_PREFIX = "ROLE_"; diff --git a/src/main/java/io/oneko/security/ApiTokenAuthenticationFilter.java b/src/main/java/io/oneko/security/ApiTokenAuthenticationFilter.java new file mode 100644 index 00000000..e10424f7 --- /dev/null +++ b/src/main/java/io/oneko/security/ApiTokenAuthenticationFilter.java @@ -0,0 +1,98 @@ +package io.oneko.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import io.oneko.configuration.ONekoUserDetailsImpl; +import io.oneko.user.ReadableUser; +import io.oneko.user.UserRepository; +import io.oneko.user.auth.ApiToken; +import io.oneko.user.auth.ApiTokenRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + private static final String TOKEN_PREFIX = "oneko_"; + + private final ApiTokenRepository apiTokenRepository; + private final UserRepository userRepository; + + public ApiTokenAuthenticationFilter(ApiTokenRepository apiTokenRepository, UserRepository userRepository) { + this.apiTokenRepository = apiTokenRepository; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(BEARER_PREFIX.length()); + + if (!token.startsWith(TOKEN_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String tokenHash = hashToken(token); + ApiToken apiToken = apiTokenRepository.getByTokenHash(tokenHash).orElse(null); + + if (apiToken == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + if (apiToken.isExpired()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + ReadableUser user = userRepository.getById(apiToken.getUserId()).orElse(null); + + if (user == null) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // Update last used timestamp + apiToken.setLastUsedAt(Instant.now()); + apiTokenRepository.save(apiToken); + + ONekoUserDetailsImpl userDetails = new ONekoUserDetailsImpl(user); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } + + public static String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/io/oneko/security/SecurityConfiguration.java b/src/main/java/io/oneko/security/SecurityConfiguration.java index 2418a950..2665b615 100644 --- a/src/main/java/io/oneko/security/SecurityConfiguration.java +++ b/src/main/java/io/oneko/security/SecurityConfiguration.java @@ -12,6 +12,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -20,10 +21,12 @@ public class SecurityConfiguration { private final ONekoUserDetailsService userDetailsService; private final SessionWebSocketHandler sessionWebSocketHandler; + private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter; - public SecurityConfiguration(ONekoUserDetailsService userDetailsService, SessionWebSocketHandler sessionWebSocketHandler) { + public SecurityConfiguration(ONekoUserDetailsService userDetailsService, SessionWebSocketHandler sessionWebSocketHandler, ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) { this.userDetailsService = userDetailsService; this.sessionWebSocketHandler = sessionWebSocketHandler; + this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter; } @Bean @@ -54,6 +57,8 @@ protected SecurityFilterChain filterChain(final HttpSecurity http) throws Except .and() .logout().logoutUrl("/api/session/logout").logoutSuccessHandler(new RestLogoutSuccessHandler(sessionWebSocketHandler)); + http.addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.httpBasic().and() .build(); } diff --git a/src/main/java/io/oneko/user/auth/ApiToken.java b/src/main/java/io/oneko/user/auth/ApiToken.java new file mode 100644 index 00000000..06e7a12d --- /dev/null +++ b/src/main/java/io/oneko/user/auth/ApiToken.java @@ -0,0 +1,26 @@ +package io.oneko.user.auth; + +import java.time.Instant; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class ApiToken { + + private UUID id; + private UUID userId; + private String name; + private String tokenHash; + private Instant createdAt; + private Instant expiresAt; + private Instant lastUsedAt; + + public boolean isExpired() { + return expiresAt != null && Instant.now().isAfter(expiresAt); + } +} diff --git a/src/main/java/io/oneko/user/auth/ApiTokenRepository.java b/src/main/java/io/oneko/user/auth/ApiTokenRepository.java new file mode 100644 index 00000000..6ea9539a --- /dev/null +++ b/src/main/java/io/oneko/user/auth/ApiTokenRepository.java @@ -0,0 +1,18 @@ +package io.oneko.user.auth; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiTokenRepository { + + Optional getByTokenHash(String tokenHash); + + List getByUserId(UUID userId); + + ApiToken save(ApiToken token); + + void deleteById(UUID id); + + void deleteAllByUserId(UUID userId); +} diff --git a/src/main/java/io/oneko/user/persistence/ApiTokenInMemoryRepository.java b/src/main/java/io/oneko/user/persistence/ApiTokenInMemoryRepository.java new file mode 100644 index 00000000..9a73e9e0 --- /dev/null +++ b/src/main/java/io/oneko/user/persistence/ApiTokenInMemoryRepository.java @@ -0,0 +1,52 @@ +package io.oneko.user.persistence; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import io.oneko.Profiles; +import io.oneko.user.auth.ApiToken; +import io.oneko.user.auth.ApiTokenRepository; + +@Service +@Profile(Profiles.IN_MEMORY) +public class ApiTokenInMemoryRepository implements ApiTokenRepository { + + private final Map tokens = new HashMap<>(); + + @Override + public Optional getByTokenHash(String tokenHash) { + return tokens.values().stream() + .filter(t -> t.getTokenHash().equals(tokenHash)) + .findFirst(); + } + + @Override + public List getByUserId(UUID userId) { + return tokens.values().stream() + .filter(t -> t.getUserId().equals(userId)) + .collect(Collectors.toList()); + } + + @Override + public ApiToken save(ApiToken token) { + tokens.put(token.getId(), token); + return token; + } + + @Override + public void deleteById(UUID id) { + tokens.remove(id); + } + + @Override + public void deleteAllByUserId(UUID userId) { + tokens.entrySet().removeIf(e -> e.getValue().getUserId().equals(userId)); + } +} diff --git a/src/main/java/io/oneko/user/persistence/ApiTokenMongo.java b/src/main/java/io/oneko/user/persistence/ApiTokenMongo.java new file mode 100644 index 00000000..2d9a428e --- /dev/null +++ b/src/main/java/io/oneko/user/persistence/ApiTokenMongo.java @@ -0,0 +1,36 @@ +package io.oneko.user.persistence; + +import java.time.Instant; +import java.util.UUID; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Document +public class ApiTokenMongo { + + @Id + private UUID id; + + @Indexed + private UUID userId; + + private String name; + + @Indexed(unique = true) + private String tokenHash; + + private Instant createdAt; + private Instant expiresAt; + private Instant lastUsedAt; +} diff --git a/src/main/java/io/oneko/user/persistence/ApiTokenMongoRepository.java b/src/main/java/io/oneko/user/persistence/ApiTokenMongoRepository.java new file mode 100644 index 00000000..d97b5fc2 --- /dev/null +++ b/src/main/java/io/oneko/user/persistence/ApiTokenMongoRepository.java @@ -0,0 +1,76 @@ +package io.oneko.user.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import io.oneko.Profiles; +import io.oneko.user.auth.ApiToken; +import io.oneko.user.auth.ApiTokenRepository; + +@Service +@Profile(Profiles.MONGO) +class ApiTokenMongoRepository implements ApiTokenRepository { + + private final ApiTokenMongoSpringRepository innerRepo; + + public ApiTokenMongoRepository(ApiTokenMongoSpringRepository innerRepo) { + this.innerRepo = innerRepo; + } + + @Override + public Optional getByTokenHash(String tokenHash) { + return innerRepo.findByTokenHash(tokenHash).map(this::fromMongo); + } + + @Override + public List getByUserId(UUID userId) { + return innerRepo.findByUserId(userId).stream() + .map(this::fromMongo) + .collect(Collectors.toList()); + } + + @Override + public ApiToken save(ApiToken token) { + ApiTokenMongo saved = innerRepo.save(toMongo(token)); + return fromMongo(saved); + } + + @Override + public void deleteById(UUID id) { + innerRepo.deleteById(id); + } + + @Override + public void deleteAllByUserId(UUID userId) { + innerRepo.deleteAllByUserId(userId); + } + + private ApiTokenMongo toMongo(ApiToken token) { + return ApiTokenMongo.builder() + .id(token.getId()) + .userId(token.getUserId()) + .name(token.getName()) + .tokenHash(token.getTokenHash()) + .createdAt(token.getCreatedAt()) + .expiresAt(token.getExpiresAt()) + .lastUsedAt(token.getLastUsedAt()) + .build(); + } + + private ApiToken fromMongo(ApiTokenMongo mongo) { + return ApiToken.builder() + .id(mongo.getId()) + .userId(mongo.getUserId()) + .name(mongo.getName()) + .tokenHash(mongo.getTokenHash()) + .createdAt(mongo.getCreatedAt()) + .expiresAt(mongo.getExpiresAt()) + .lastUsedAt(mongo.getLastUsedAt()) + .build(); + } +} diff --git a/src/main/java/io/oneko/user/persistence/ApiTokenMongoSpringRepository.java b/src/main/java/io/oneko/user/persistence/ApiTokenMongoSpringRepository.java new file mode 100644 index 00000000..030fb7f2 --- /dev/null +++ b/src/main/java/io/oneko/user/persistence/ApiTokenMongoSpringRepository.java @@ -0,0 +1,16 @@ +package io.oneko.user.persistence; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.mongodb.repository.MongoRepository; + +interface ApiTokenMongoSpringRepository extends MongoRepository { + + Optional findByTokenHash(String tokenHash); + + List findByUserId(UUID userId); + + void deleteAllByUserId(UUID userId); +} diff --git a/src/main/java/io/oneko/user/rest/ApiTokenController.java b/src/main/java/io/oneko/user/rest/ApiTokenController.java new file mode 100644 index 00000000..a94ddca8 --- /dev/null +++ b/src/main/java/io/oneko/user/rest/ApiTokenController.java @@ -0,0 +1,106 @@ +package io.oneko.user.rest; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.oneko.configuration.Controllers; +import io.oneko.security.ApiTokenAuthenticationFilter; +import io.oneko.user.ReadableUser; +import io.oneko.user.UserRepository; +import io.oneko.user.auth.ApiToken; +import io.oneko.user.auth.ApiTokenRepository; +import lombok.extern.slf4j.Slf4j; + +@RestController +@Slf4j +@RequestMapping(ApiTokenController.PATH) +public class ApiTokenController { + + public static final String PATH = Controllers.ROOT_PATH + "/user/{userName}/tokens"; + + private static final String TOKEN_PREFIX = "oneko_"; + private static final int TOKEN_RANDOM_BYTES = 20; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final ApiTokenRepository apiTokenRepository; + private final UserRepository userRepository; + + public ApiTokenController(ApiTokenRepository apiTokenRepository, UserRepository userRepository) { + this.apiTokenRepository = apiTokenRepository; + this.userRepository = userRepository; + } + + @PreAuthorize("hasRole('ADMIN') OR #userName == authentication.name") + @GetMapping + List getTokens(@PathVariable String userName) { + ReadableUser user = getUserOrThrow(userName); + return apiTokenRepository.getByUserId(user.getId()).stream() + .map(ApiTokenDTO::fromApiToken) + .collect(Collectors.toList()); + } + + @PreAuthorize("hasRole('ADMIN') OR #userName == authentication.name") + @PostMapping + ApiTokenDTO.CreateApiTokenResponse createToken(@PathVariable String userName, @RequestBody ApiTokenDTO.CreateApiTokenRequest request) { + ReadableUser user = getUserOrThrow(userName); + + if (request.getExpiresAt() != null) { + Instant maxExpiry = Instant.now().plus(365, java.time.temporal.ChronoUnit.DAYS); + if (request.getExpiresAt().isAfter(maxExpiry)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token expiry cannot be more than 1 year from now."); + } + } + + String rawToken = generateRawToken(); + String tokenHash = ApiTokenAuthenticationFilter.hashToken(rawToken); + + ApiToken apiToken = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(user.getId()) + .name(request.getName()) + .tokenHash(tokenHash) + .createdAt(Instant.now()) + .expiresAt(request.getExpiresAt()) + .build(); + + ApiToken saved = apiTokenRepository.save(apiToken); + + log.info("API token '{}' created for user '{}'", request.getName(), userName); + + return new ApiTokenDTO.CreateApiTokenResponse(ApiTokenDTO.fromApiToken(saved), rawToken); + } + + @PreAuthorize("hasRole('ADMIN') OR #userName == authentication.name") + @DeleteMapping("/{tokenId}") + void deleteToken(@PathVariable String userName, @PathVariable UUID tokenId) { + getUserOrThrow(userName); + apiTokenRepository.deleteById(tokenId); + log.info("API token '{}' deleted for user '{}'", tokenId, userName); + } + + private ReadableUser getUserOrThrow(String userName) { + return userRepository.getByUserName(userName) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User with name " + userName + " not found.")); + } + + private String generateRawToken() { + byte[] randomBytes = new byte[TOKEN_RANDOM_BYTES]; + SECURE_RANDOM.nextBytes(randomBytes); + return TOKEN_PREFIX + HexFormat.of().formatHex(randomBytes); + } +} diff --git a/src/main/java/io/oneko/user/rest/ApiTokenDTO.java b/src/main/java/io/oneko/user/rest/ApiTokenDTO.java new file mode 100644 index 00000000..bf70e745 --- /dev/null +++ b/src/main/java/io/oneko/user/rest/ApiTokenDTO.java @@ -0,0 +1,45 @@ +package io.oneko.user.rest; + +import java.time.Instant; +import java.util.UUID; + +import io.oneko.user.auth.ApiToken; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ApiTokenDTO { + + private UUID id; + private String name; + private Instant createdAt; + private Instant expiresAt; + private Instant lastUsedAt; + + public static ApiTokenDTO fromApiToken(ApiToken token) { + ApiTokenDTO dto = new ApiTokenDTO(); + dto.setId(token.getId()); + dto.setName(token.getName()); + dto.setCreatedAt(token.getCreatedAt()); + dto.setExpiresAt(token.getExpiresAt()); + dto.setLastUsedAt(token.getLastUsedAt()); + return dto; + } + + /** + * Response for token creation. Contains the raw token which is only shown once. + */ + @Data + @NoArgsConstructor + public static class CreateApiTokenRequest { + private String name; + private Instant expiresAt; + } + + @Data + public static class CreateApiTokenResponse { + private final ApiTokenDTO token; + private final String rawToken; + } +} diff --git a/src/main/java/io/oneko/user/rest/UserController.java b/src/main/java/io/oneko/user/rest/UserController.java index fae31c32..2a8bbfaf 100644 --- a/src/main/java/io/oneko/user/rest/UserController.java +++ b/src/main/java/io/oneko/user/rest/UserController.java @@ -24,6 +24,7 @@ import io.oneko.user.User; import io.oneko.user.UserRepository; import io.oneko.user.WritableUser; +import io.oneko.user.auth.ApiTokenRepository; import lombok.extern.slf4j.Slf4j; @RestController @@ -36,11 +37,13 @@ public class UserController { private final UserRepository userRepository; private final UserDTOMapper dtoMapper; private final PasswordEncoder passwordEncoder; + private final ApiTokenRepository apiTokenRepository; - public UserController(UserRepository userRepository, UserDTOMapper dtoMapper, PasswordEncoder passwordEncoder) { + public UserController(UserRepository userRepository, UserDTOMapper dtoMapper, PasswordEncoder passwordEncoder, ApiTokenRepository apiTokenRepository) { this.userRepository = userRepository; this.dtoMapper = dtoMapper; this.passwordEncoder = passwordEncoder; + this.apiTokenRepository = apiTokenRepository; } @PreAuthorize("hasRole('ADMIN')") @@ -103,6 +106,7 @@ UserDTO changePassword(@PathVariable String userName, @RequestBody ChangePasswor void deleteUser(@PathVariable String userName) { ReadableUser user = this.userRepository.getByUserName(userName) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User with name " + userName + "not found.")); + this.apiTokenRepository.deleteAllByUserId(user.getId()); this.userRepository.removeUser(user); } diff --git a/src/test/java/io/oneko/security/ApiTokenAuthenticationFilterTest.java b/src/test/java/io/oneko/security/ApiTokenAuthenticationFilterTest.java new file mode 100644 index 00000000..08fc4ac7 --- /dev/null +++ b/src/test/java/io/oneko/security/ApiTokenAuthenticationFilterTest.java @@ -0,0 +1,27 @@ +package io.oneko.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ApiTokenAuthenticationFilterTest { + + @Test + void testHashTokenProducesDeterministicResult() { + String token = "oneko_abc123def456"; + String hash1 = ApiTokenAuthenticationFilter.hashToken(token); + String hash2 = ApiTokenAuthenticationFilter.hashToken(token); + + assertThat(hash1).isEqualTo(hash2); + assertThat(hash1).isNotEmpty(); + assertThat(hash1).hasSize(64); // SHA-256 produces 64 hex chars + } + + @Test + void testDifferentTokensProduceDifferentHashes() { + String hash1 = ApiTokenAuthenticationFilter.hashToken("oneko_token1"); + String hash2 = ApiTokenAuthenticationFilter.hashToken("oneko_token2"); + + assertThat(hash1).isNotEqualTo(hash2); + } +} diff --git a/src/test/java/io/oneko/user/persistence/ApiTokenInMemoryRepositoryTest.java b/src/test/java/io/oneko/user/persistence/ApiTokenInMemoryRepositoryTest.java new file mode 100644 index 00000000..937f41fb --- /dev/null +++ b/src/test/java/io/oneko/user/persistence/ApiTokenInMemoryRepositoryTest.java @@ -0,0 +1,121 @@ +package io.oneko.user.persistence; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.oneko.user.auth.ApiToken; + +class ApiTokenInMemoryRepositoryTest { + + private ApiTokenInMemoryRepository uut; + + @BeforeEach + void setup() { + uut = new ApiTokenInMemoryRepository(); + } + + @Test + void testCrud() { + UUID userId = UUID.randomUUID(); + ApiToken token = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(userId) + .name("test-token") + .tokenHash("abc123hash") + .createdAt(Instant.now()) + .build(); + + uut.save(token); + assertThat(uut.getByUserId(userId)).hasSize(1); + + uut.deleteById(token.getId()); + assertThat(uut.getByUserId(userId)).isEmpty(); + } + + @Test + void testGetByTokenHash() { + ApiToken token = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .name("my-token") + .tokenHash("unique-hash-value") + .createdAt(Instant.now()) + .build(); + + uut.save(token); + assertThat(uut.getByTokenHash("unique-hash-value")).isPresent(); + assertThat(uut.getByTokenHash("nonexistent")).isEmpty(); + } + + @Test + void testDeleteAllByUserId() { + UUID userId = UUID.randomUUID(); + + for (int i = 0; i < 3; i++) { + uut.save(ApiToken.builder() + .id(UUID.randomUUID()) + .userId(userId) + .name("token-" + i) + .tokenHash("hash-" + i) + .createdAt(Instant.now()) + .build()); + } + + // Also save a token for a different user + UUID otherUserId = UUID.randomUUID(); + uut.save(ApiToken.builder() + .id(UUID.randomUUID()) + .userId(otherUserId) + .name("other-token") + .tokenHash("other-hash") + .createdAt(Instant.now()) + .build()); + + assertThat(uut.getByUserId(userId)).hasSize(3); + assertThat(uut.getByUserId(otherUserId)).hasSize(1); + + uut.deleteAllByUserId(userId); + + assertThat(uut.getByUserId(userId)).isEmpty(); + assertThat(uut.getByUserId(otherUserId)).hasSize(1); + } + + @Test + void testIsExpired() { + ApiToken expiredToken = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .name("expired") + .tokenHash("expired-hash") + .createdAt(Instant.now().minus(2, ChronoUnit.DAYS)) + .expiresAt(Instant.now().minus(1, ChronoUnit.DAYS)) + .build(); + + ApiToken validToken = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .name("valid") + .tokenHash("valid-hash") + .createdAt(Instant.now()) + .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS)) + .build(); + + ApiToken noExpiryToken = ApiToken.builder() + .id(UUID.randomUUID()) + .userId(UUID.randomUUID()) + .name("no-expiry") + .tokenHash("no-expiry-hash") + .createdAt(Instant.now()) + .build(); + + assertThat(expiredToken.isExpired()).isTrue(); + assertThat(validToken.isExpired()).isFalse(); + assertThat(noExpiryToken.isExpired()).isFalse(); + } +}