Created structure for web-app's js client library for consuming onyx api.

This commit is contained in:
Andrew Lalis 2023-10-27 16:23:56 -04:00
parent 321bad46a7
commit c7bf9d9058
10 changed files with 397 additions and 13 deletions

View File

@ -0,0 +1,6 @@
package com.andrewlalis.onyx.auth.api;
public record AccessTokenResponse(
String accessToken,
long expiresAt
) {}

View File

@ -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));
}
}

View File

@ -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
) {}

View File

@ -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<Claims> jws = getToken(request);
return jws.getBody().getExpiration().getTime();
} catch (Exception e) {
log.warn("Exception occurred while getting token expiration.", e);
return -1;
}
}
public Jws<Claims> getToken(HttpServletRequest request) throws Exception {
String rawToken = extractBearerToken(request);
if (rawToken == null) return null;

View File

@ -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);

View File

@ -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",

View File

@ -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"

View File

@ -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<TokenPair> {
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<TokenData> {
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<number> {
const data: any = await this.api.request({url: "/auth/token-expiration", headers: {"Authorization": "Bearer " + token}}, true);
return data.expiresAt;
}
async removeAllRefreshTokens(): Promise<void> {
await this.api.request({method: "DELETE", url: "/auth/refresh-tokens"})
}
}

View File

@ -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<ContainerNode | DocumentNode> {
return await this.api.request({url: "/content/nodes/" + id});
}
async getRoot(): Promise<ContainerNode> {
return await this.api.request({url: "/content/nodes/root"});
}
async createContainer(parentNodeId: number, data: ContainerNodeCreationData): Promise<ContainerNode> {
return await this.api.request({
method: "POST",
url: "/content/nodes/" + parentNodeId + "/children",
data: data
});
}
async createDocument(parentNodeId: number, data: DocumentNodeCreationData): Promise<DocumentNode> {
return await this.api.request({
method: "POST",
url: "/content/nodes/" + parentNodeId + "/children",
data: data
});
}
async deleteNode(id: number): Promise<void> {
await this.api.request({method: "DELETE", url: "/content/nodes/" + id});
}
}

View File

@ -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<OnyxApi> {
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<void> {
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<R>(config: AxiosRequestConfig, anon: boolean = false): Promise<R> {
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();
}
}