diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AccessTokenResponse.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AccessTokenResponse.java new file mode 100644 index 0000000..6fa7268 --- /dev/null +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AccessTokenResponse.java @@ -0,0 +1,6 @@ +package com.andrewlalis.onyx.auth.api; + +public record AccessTokenResponse( + String accessToken, + long expiresAt +) {} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java index ba4dda1..f02b4c2 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/AuthController.java @@ -20,12 +20,17 @@ public class AuthController { } @GetMapping("/auth/access") - public Object getAccessToken(HttpServletRequest request) { - return Map.of("accessToken", tokenService.generateAccessToken(request)); + public AccessTokenResponse getAccessToken(HttpServletRequest request) { + return tokenService.generateAccessToken(request); } @DeleteMapping("/auth/refresh-tokens") public void removeAllRefreshTokens(@AuthenticationPrincipal User user) { tokenService.removeAllRefreshTokens(user); } + + @GetMapping("/auth/token-expiration") + public Object getTokenExpiration(HttpServletRequest request) { + return Map.of("expiresAt", tokenService.getTokenExpiration(request)); + } } diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java index 8aa3d41..d7655ea 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/api/TokenPair.java @@ -1,3 +1,8 @@ package com.andrewlalis.onyx.auth.api; -public record TokenPair(String refreshToken, String accessToken) {} +public record TokenPair( + String refreshToken, + long refreshTokenExpiresAt, + String accessToken, + long accessTokenExpiresAt +) {} diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java index a9f88f6..bc056a2 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/auth/service/TokenService.java @@ -1,5 +1,6 @@ package com.andrewlalis.onyx.auth.service; +import com.andrewlalis.onyx.auth.api.AccessTokenResponse; import com.andrewlalis.onyx.auth.api.LoginRequest; import com.andrewlalis.onyx.auth.api.TokenPair; import com.andrewlalis.onyx.auth.model.RefreshToken; @@ -14,7 +15,6 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +29,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.Optional; @@ -38,6 +39,8 @@ import java.util.Optional; public class TokenService { private static final String BEARER_PREFIX = "Bearer "; private static final String ISSUER = "Onyx API"; + private static final int REFRESH_TOKEN_EXPIRATION_DAYS = 30; + private static final int ACCESS_TOKEN_EXPIRATION_MINUTES = 120; private PrivateKey signingKey; private final PasswordEncoder passwordEncoder; @@ -54,14 +57,16 @@ public class TokenService { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); } User user = optionalUser.get(); - String refreshToken = generateRefreshToken(user); - String accessToken = generateAccessToken(refreshToken); - return new TokenPair(refreshToken, accessToken); + final Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant(); + Instant refreshTokenExpiration = now.plus(REFRESH_TOKEN_EXPIRATION_DAYS, ChronoUnit.DAYS); + String refreshToken = generateRefreshToken(user, refreshTokenExpiration); + Instant accessTokenExpiration = now.plus(ACCESS_TOKEN_EXPIRATION_MINUTES, ChronoUnit.MINUTES); + String accessToken = generateAccessToken(refreshToken, accessTokenExpiration); + return new TokenPair(refreshToken, refreshTokenExpiration.toEpochMilli(), accessToken, accessTokenExpiration.toEpochMilli()); } @Transactional - public String generateRefreshToken(User user) { - Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(7).toInstant(); + public String generateRefreshToken(User user, Instant expiresAt) { try { String token = Jwts.builder() .setSubject(Long.toString(user.getId())) @@ -86,7 +91,7 @@ public class TokenService { } @Transactional(readOnly = true) - public String generateAccessToken(String refreshTokenString) { + public String generateAccessToken(String refreshTokenString, Instant expiresAt) { String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH); RefreshToken refreshToken = null; for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) { @@ -101,7 +106,6 @@ public class TokenService { if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired."); } - Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).toInstant(); try { return Jwts.builder() .setSubject(Long.toString(refreshToken.getUser().getId())) @@ -119,12 +123,16 @@ public class TokenService { } @Transactional(readOnly = true) - public String generateAccessToken(HttpServletRequest request) { + public AccessTokenResponse generateAccessToken(HttpServletRequest request) { String refreshTokenString = extractBearerToken(request); if (refreshTokenString == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token."); } - return generateAccessToken(refreshTokenString); + Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(ACCESS_TOKEN_EXPIRATION_MINUTES).toInstant(); + return new AccessTokenResponse( + generateAccessToken(refreshTokenString, expiresAt), + expiresAt.toEpochMilli() + ); } @Transactional @@ -132,6 +140,16 @@ public class TokenService { refreshTokenRepository.deleteAllByUserId(user.getId()); } + public long getTokenExpiration(HttpServletRequest request) { + try { + Jws jws = getToken(request); + return jws.getBody().getExpiration().getTime(); + } catch (Exception e) { + log.warn("Exception occurred while getting token expiration.", e); + return -1; + } + } + public Jws getToken(HttpServletRequest request) throws Exception { String rawToken = extractBearerToken(request); if (rawToken == null) return null; diff --git a/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java index a4da734..1b19518 100644 --- a/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java +++ b/onyx-api/src/main/java/com/andrewlalis/onyx/config/SecurityConfig.java @@ -27,6 +27,7 @@ public class SecurityConfig { http.authorizeHttpRequests(registry -> { registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll(); registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/access")).permitAll(); + registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/token-expiration")).permitAll(); registry.anyRequest().authenticated(); }); http.csrf(AbstractHttpConfigurer::disable); diff --git a/onyx-web-app/package-lock.json b/onyx-web-app/package-lock.json index 29cb94b..e576dcc 100644 --- a/onyx-web-app/package-lock.json +++ b/onyx-web-app/package-lock.json @@ -8,6 +8,7 @@ "name": "onyx-web-app", "version": "0.0.0", "dependencies": { + "axios": "^1.6.0", "pinia": "^2.1.7", "vue": "^3.3.4", "vue-router": "^4.2.5" @@ -1241,6 +1242,21 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1318,6 +1334,17 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/computeds": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", @@ -1390,6 +1417,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1755,6 +1790,38 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2166,6 +2233,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2564,6 +2650,11 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", diff --git a/onyx-web-app/package.json b/onyx-web-app/package.json index 8b57ddf..c0a052a 100644 --- a/onyx-web-app/package.json +++ b/onyx-web-app/package.json @@ -11,6 +11,7 @@ "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" }, "dependencies": { + "axios": "^1.6.0", "pinia": "^2.1.7", "vue": "^3.3.4", "vue-router": "^4.2.5" diff --git a/onyx-web-app/src/api-client/onyx-api-auth.ts b/onyx-web-app/src/api-client/onyx-api-auth.ts new file mode 100644 index 0000000..b532426 --- /dev/null +++ b/onyx-web-app/src/api-client/onyx-api-auth.ts @@ -0,0 +1,48 @@ +import type {OnyxApi} from "@/api-client/onyx-api"; + +export interface LoginInfo { + username: string; + password: string; +} + +export interface TokenData { + token: string; + expiresAt: number; +} + +interface TokenPair { + accessToken: TokenData; + refreshToken: TokenData; +} + +/** + * Authentication endpoints in the Onyx API. + */ +export class OnyxAuthApi { + constructor(readonly api: OnyxApi) {} + + async login(loginInfo: LoginInfo): Promise { + const data: any = await this.api.request({method: "POST", url: "/auth/login", data: loginInfo}, true) + return { + accessToken: {token: data.accessToken, expiresAt: data.accessTokenExpiresAt}, + refreshToken: {token: data.refreshToken, expiresAt: data.refreshTokenExpiresAt} + } + } + + async getAccessToken(refreshToken: string): Promise { + const data: any = await this.api.request({url: "/auth/access", headers: {"Authorization": "Bearer " + refreshToken}}); + return { + token: data.accessToken, + expiresAt: data.expiresAt + }; + } + + async getTokenExpiration(token: string): Promise { + const data: any = await this.api.request({url: "/auth/token-expiration", headers: {"Authorization": "Bearer " + token}}, true); + return data.expiresAt; + } + + async removeAllRefreshTokens(): Promise { + await this.api.request({method: "DELETE", url: "/auth/refresh-tokens"}) + } +} \ No newline at end of file diff --git a/onyx-web-app/src/api-client/onyx-api-content.ts b/onyx-web-app/src/api-client/onyx-api-content.ts new file mode 100644 index 0000000..3a2ac76 --- /dev/null +++ b/onyx-web-app/src/api-client/onyx-api-content.ts @@ -0,0 +1,67 @@ +import type {OnyxApi} from "@/api-client/onyx-api"; + +export interface ContentNode { + id: number; + name: string; + nodeType: string; + archived: boolean; +} + +export interface ContainerChildData { + id: number; + name: string; + nodeType: string; +} + +export interface ContainerNode extends ContentNode { + children: ContainerChildData[] +} + +export interface DocumentNode extends ContentNode { + contentType: string; +} + +export interface ContainerNodeCreationData { + name: string; +} + +export interface DocumentNodeCreationData { + name: string; + contentType: string; + content: string; +} + +/** + * The API for interacting with nodes in an Onyx instance's content tree. + */ +export class OnyxContentApi { + constructor(readonly api: OnyxApi) {} + + async getNode(id: number): Promise { + return await this.api.request({url: "/content/nodes/" + id}); + } + + async getRoot(): Promise { + return await this.api.request({url: "/content/nodes/root"}); + } + + async createContainer(parentNodeId: number, data: ContainerNodeCreationData): Promise { + return await this.api.request({ + method: "POST", + url: "/content/nodes/" + parentNodeId + "/children", + data: data + }); + } + + async createDocument(parentNodeId: number, data: DocumentNodeCreationData): Promise { + return await this.api.request({ + method: "POST", + url: "/content/nodes/" + parentNodeId + "/children", + data: data + }); + } + + async deleteNode(id: number): Promise { + await this.api.request({method: "DELETE", url: "/content/nodes/" + id}); + } +} \ No newline at end of file diff --git a/onyx-web-app/src/api-client/onyx-api.ts b/onyx-web-app/src/api-client/onyx-api.ts new file mode 100644 index 0000000..d5aa8d7 --- /dev/null +++ b/onyx-web-app/src/api-client/onyx-api.ts @@ -0,0 +1,142 @@ +import type {AxiosRequestConfig} from "axios"; +import axios, {Axios} from "axios"; +import {OnyxAuthApi} from "@/api-client/onyx-api-auth"; +import type{LoginInfo, TokenData} from "@/api-client/onyx-api-auth"; +import {OnyxContentApi} from "@/api-client/onyx-api-content"; + +interface OnyxAuthState { + accessToken: TokenData | null; + refreshToken: TokenData | null; +} + +export class OnyxApi { + private authState: OnyxAuthState = {accessToken: null, refreshToken: null}; + private accessTokenIntervalId: number | null = null; + private httpClient: Axios; + + readonly auth: OnyxAuthApi = new OnyxAuthApi(this); + readonly content: OnyxContentApi = new OnyxContentApi(this); + + constructor() { + this.httpClient = axios.create({ + baseURL: "http://localhost:8080/", + timeout: 3000 + }); + } + + /** + * Loads a new API instance from information in the user's local storage, + * which should ideally have a stored refresh token and its expiration date. + * Using this information, we can fetch a new access token and any other auth + * data. Will return an API instance that's authenticated if we have the info + * to do so, and all necessary services are available. Otherwise, an + * unauthenticated instance is returned. + */ + static async loadFromLocalStorage(): Promise { + const refreshToken = localStorage.getItem("onyx-api-refresh-token"); + const expirationStr = localStorage.getItem("onyx-api-refresh-token-expiration"); + let expiration = (expirationStr !== null) ? parseInt(expirationStr, 10): null; + const api = new OnyxApi(); + // If no expiration was stored, try and get that first. + if (refreshToken && expiration === null) { + try { + expiration = await api.auth.getTokenExpiration(refreshToken); + } catch (error: any) { + console.error(error); + return api; + } + } + if (expiration === null) return api; // This is to make the type-checker happy. + // If we have a valid refresh token that's not expired, get an access token and init the auth state. + if (refreshToken && expiration > Date.now()) { + try { + api.authState.accessToken = await api.auth.getAccessToken(refreshToken); + api.authState.refreshToken = {token: refreshToken, expiresAt: expiration}; + api.saveAuthStateToLocalStorage(); + } catch (error: any) { + console.error(error); + } + } + return api; + } + + private saveAuthStateToLocalStorage() { + if (this.authState.refreshToken) { + localStorage.setItem("onyx-api-refresh-token", this.authState.refreshToken?.token); + localStorage.setItem("onyx-api-refresh-token-expiration", this.authState.refreshToken.expiresAt.toString(10)); + } else { + localStorage.removeItem("onyx-api-refresh-token"); + localStorage.removeItem("onyx-api-refresh-token-expiration"); + } + console.log("Saved current auth state to local storage.", this.authState.refreshToken); + } + + private initAccessTokenRefreshing() { + if (this.accessTokenIntervalId) { + window.clearInterval(this.accessTokenIntervalId); + } + this.accessTokenIntervalId = window.setInterval(async () => { + if (this.authState.refreshToken && this.authState.accessToken && this.authState.accessToken.expiresAt < Date.now() + 5000) { + this.authState.accessToken = await this.auth.getAccessToken(this.authState.refreshToken.token); + this.saveAuthStateToLocalStorage(); + console.log("Updated access token."); + } + }, 5000); + } + + /** + * Attempts to do a login with the given credentials to obtain a new refresh token. + * @param credentials The credentials to use. + */ + async login(credentials: LoginInfo): Promise { + const tokens = await this.auth.login(credentials); + this.authState = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + }; + this.saveAuthStateToLocalStorage(); + } + + /** + * Logs this instance out if it was logged in. + */ + logout() { + if (this.accessTokenIntervalId) { + window.clearInterval(this.accessTokenIntervalId); + } + this.authState = {accessToken: null, refreshToken: null}; + this.saveAuthStateToLocalStorage(); + } + + /** + * Makes a request to the Onyx API, setting the Authorization header when + * this API instance is authenticated. + * @param config The axios request object. + * @param anon Whether to make the request anonymous (no auth). + */ + async request(config: AxiosRequestConfig, anon: boolean = false): Promise { + if (!config.headers) config.headers = {}; + if (!anon && this.isAuthenticated()) { + config.headers["Authorization"] = "Bearer " + this.authState.accessToken?.token; + } + if (config.data) { + config.headers["Content-Type"] = "application/json"; + } + const response = await this.httpClient.request(config); + if (response.status === 200) { + return response.data; + } + throw response.status; + } + + /** + * Tells whether this API instance is authenticated and likely able to + * access endpoints that require authentication. + */ + isAuthenticated(): boolean { + return this.authState.accessToken !== null && + this.authState.accessToken.expiresAt > Date.now() && + this.authState.refreshToken !== null && + this.authState.refreshToken.expiresAt > Date.now(); + } +}