Created structure for web-app's js client library for consuming onyx api.
This commit is contained in:
parent
321bad46a7
commit
c7bf9d9058
|
@ -0,0 +1,6 @@
|
||||||
|
package com.andrewlalis.onyx.auth.api;
|
||||||
|
|
||||||
|
public record AccessTokenResponse(
|
||||||
|
String accessToken,
|
||||||
|
long expiresAt
|
||||||
|
) {}
|
|
@ -20,12 +20,17 @@ public class AuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/auth/access")
|
@GetMapping("/auth/access")
|
||||||
public Object getAccessToken(HttpServletRequest request) {
|
public AccessTokenResponse getAccessToken(HttpServletRequest request) {
|
||||||
return Map.of("accessToken", tokenService.generateAccessToken(request));
|
return tokenService.generateAccessToken(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/auth/refresh-tokens")
|
@DeleteMapping("/auth/refresh-tokens")
|
||||||
public void removeAllRefreshTokens(@AuthenticationPrincipal User user) {
|
public void removeAllRefreshTokens(@AuthenticationPrincipal User user) {
|
||||||
tokenService.removeAllRefreshTokens(user);
|
tokenService.removeAllRefreshTokens(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/auth/token-expiration")
|
||||||
|
public Object getTokenExpiration(HttpServletRequest request) {
|
||||||
|
return Map.of("expiresAt", tokenService.getTokenExpiration(request));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
package com.andrewlalis.onyx.auth.api;
|
package com.andrewlalis.onyx.auth.api;
|
||||||
|
|
||||||
public record TokenPair(String refreshToken, String accessToken) {}
|
public record TokenPair(
|
||||||
|
String refreshToken,
|
||||||
|
long refreshTokenExpiresAt,
|
||||||
|
String accessToken,
|
||||||
|
long accessTokenExpiresAt
|
||||||
|
) {}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.andrewlalis.onyx.auth.service;
|
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.LoginRequest;
|
||||||
import com.andrewlalis.onyx.auth.api.TokenPair;
|
import com.andrewlalis.onyx.auth.api.TokenPair;
|
||||||
import com.andrewlalis.onyx.auth.model.RefreshToken;
|
import com.andrewlalis.onyx.auth.model.RefreshToken;
|
||||||
|
@ -14,7 +15,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
@ -29,6 +29,7 @@ import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ -38,6 +39,8 @@ import java.util.Optional;
|
||||||
public class TokenService {
|
public class TokenService {
|
||||||
private static final String BEARER_PREFIX = "Bearer ";
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
private static final String ISSUER = "Onyx API";
|
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 PrivateKey signingKey;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
@ -54,14 +57,16 @@ public class TokenService {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
|
||||||
}
|
}
|
||||||
User user = optionalUser.get();
|
User user = optionalUser.get();
|
||||||
String refreshToken = generateRefreshToken(user);
|
final Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant();
|
||||||
String accessToken = generateAccessToken(refreshToken);
|
Instant refreshTokenExpiration = now.plus(REFRESH_TOKEN_EXPIRATION_DAYS, ChronoUnit.DAYS);
|
||||||
return new TokenPair(refreshToken, accessToken);
|
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
|
@Transactional
|
||||||
public String generateRefreshToken(User user) {
|
public String generateRefreshToken(User user, Instant expiresAt) {
|
||||||
Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(7).toInstant();
|
|
||||||
try {
|
try {
|
||||||
String token = Jwts.builder()
|
String token = Jwts.builder()
|
||||||
.setSubject(Long.toString(user.getId()))
|
.setSubject(Long.toString(user.getId()))
|
||||||
|
@ -86,7 +91,7 @@ public class TokenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public String generateAccessToken(String refreshTokenString) {
|
public String generateAccessToken(String refreshTokenString, Instant expiresAt) {
|
||||||
String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH);
|
String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH);
|
||||||
RefreshToken refreshToken = null;
|
RefreshToken refreshToken = null;
|
||||||
for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) {
|
for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) {
|
||||||
|
@ -101,7 +106,6 @@ public class TokenService {
|
||||||
if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) {
|
if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired.");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired.");
|
||||||
}
|
}
|
||||||
Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).toInstant();
|
|
||||||
try {
|
try {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setSubject(Long.toString(refreshToken.getUser().getId()))
|
.setSubject(Long.toString(refreshToken.getUser().getId()))
|
||||||
|
@ -119,12 +123,16 @@ public class TokenService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public String generateAccessToken(HttpServletRequest request) {
|
public AccessTokenResponse generateAccessToken(HttpServletRequest request) {
|
||||||
String refreshTokenString = extractBearerToken(request);
|
String refreshTokenString = extractBearerToken(request);
|
||||||
if (refreshTokenString == null) {
|
if (refreshTokenString == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token.");
|
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
|
@Transactional
|
||||||
|
@ -132,6 +140,16 @@ public class TokenService {
|
||||||
refreshTokenRepository.deleteAllByUserId(user.getId());
|
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 {
|
public Jws<Claims> getToken(HttpServletRequest request) throws Exception {
|
||||||
String rawToken = extractBearerToken(request);
|
String rawToken = extractBearerToken(request);
|
||||||
if (rawToken == null) return null;
|
if (rawToken == null) return null;
|
||||||
|
|
|
@ -27,6 +27,7 @@ public class SecurityConfig {
|
||||||
http.authorizeHttpRequests(registry -> {
|
http.authorizeHttpRequests(registry -> {
|
||||||
registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll();
|
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/access")).permitAll();
|
||||||
|
registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/token-expiration")).permitAll();
|
||||||
registry.anyRequest().authenticated();
|
registry.anyRequest().authenticated();
|
||||||
});
|
});
|
||||||
http.csrf(AbstractHttpConfigurer::disable);
|
http.csrf(AbstractHttpConfigurer::disable);
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "onyx-web-app",
|
"name": "onyx-web-app",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
|
@ -1241,6 +1242,21 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
@ -1318,6 +1334,17 @@
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/computeds": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz",
|
"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==",
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
|
@ -1755,6 +1790,38 @@
|
||||||
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
|
"integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
@ -2166,6 +2233,25 @@
|
||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
|
@ -2564,6 +2650,11 @@
|
||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
|
|
|
@ -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"})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue