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(); + } +}