mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-01-24 14:24:47 +08:00
feat(auth): rework auth system implementation to be more reliable and cleaner
This commit is contained in:
parent
5c409f7831
commit
5470d5b07e
@ -54,8 +54,8 @@ public class LoginController extends HangarComponent {
|
||||
config.checkDev();
|
||||
|
||||
UserTable fakeUser = authenticationService.loginAsFakeUser();
|
||||
tokenService.issueRefreshAndAccessToken(fakeUser);
|
||||
return new RedirectView(returnUrl);
|
||||
tokenService.issueRefreshToken(fakeUser);
|
||||
return addBaseAndRedirect(returnUrl);
|
||||
} else {
|
||||
response.addCookie(new Cookie("url", returnUrl));
|
||||
return redirectToSso(ssoService.getLoginUrl(config.getBaseUrl() + "/login"));
|
||||
@ -73,14 +73,14 @@ public class LoginController extends HangarComponent {
|
||||
if (!validationService.isValidUsername(user.getName())) {
|
||||
throw new HangarApiException("nav.user.error.invalidUsername");
|
||||
}
|
||||
tokenService.issueRefreshAndAccessToken(user);
|
||||
tokenService.issueRefreshToken(user);
|
||||
return addBaseAndRedirect(url);
|
||||
}
|
||||
|
||||
@GetMapping("/refresh")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void refreshAccessToken(@CookieValue(name = SecurityConfig.REFRESH_COOKIE_NAME, required = false) String refreshToken) {
|
||||
tokenService.refreshAccessToken(refreshToken);
|
||||
public String refreshAccessToken(@CookieValue(name = SecurityConfig.REFRESH_COOKIE_NAME, required = false) String refreshToken) {
|
||||
return tokenService.refreshAccessToken(refreshToken).accessToken();
|
||||
}
|
||||
|
||||
@GetMapping("/invalidate")
|
||||
@ -105,7 +105,7 @@ public class LoginController extends HangarComponent {
|
||||
return redirectToSso(ssoService.getLogoutUrl(config.getBaseUrl() + "/handle-logout", principal.get()));
|
||||
} else {
|
||||
tokenService.invalidateToken(null);
|
||||
return new RedirectView(returnUrl);
|
||||
return addBaseAndRedirect(returnUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,8 @@ import io.papermc.hangar.security.annotations.permission.PermissionRequired;
|
||||
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
|
||||
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
|
||||
import io.papermc.hangar.security.authentication.HangarAuthenticationToken;
|
||||
import io.papermc.hangar.security.configs.SecurityConfig;
|
||||
import io.papermc.hangar.service.TokenService;
|
||||
import io.papermc.hangar.service.api.UsersApiService;
|
||||
import io.papermc.hangar.service.internal.perms.roles.OrganizationRoleService;
|
||||
import io.papermc.hangar.service.internal.perms.roles.ProjectRoleService;
|
||||
@ -45,6 +47,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.CookieValue;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -68,9 +71,10 @@ public class HangarUserController extends HangarComponent {
|
||||
private final OrganizationRoleService organizationRoleService;
|
||||
private final ProjectInviteService projectInviteService;
|
||||
private final OrganizationInviteService organizationInviteService;
|
||||
private final TokenService tokenService;
|
||||
|
||||
@Autowired
|
||||
public HangarUserController(ObjectMapper mapper, UsersApiService usersApiService, UserService userService, NotificationService notificationService, ProjectRoleService projectRoleService, OrganizationRoleService organizationRoleService, ProjectInviteService projectInviteService, OrganizationInviteService organizationInviteService) {
|
||||
public HangarUserController(ObjectMapper mapper, UsersApiService usersApiService, UserService userService, NotificationService notificationService, ProjectRoleService projectRoleService, OrganizationRoleService organizationRoleService, ProjectInviteService projectInviteService, OrganizationInviteService organizationInviteService, final TokenService tokenService) {
|
||||
this.mapper = mapper;
|
||||
this.usersApiService = usersApiService;
|
||||
this.userService = userService;
|
||||
@ -79,15 +83,38 @@ public class HangarUserController extends HangarComponent {
|
||||
this.organizationRoleService = organizationRoleService;
|
||||
this.projectInviteService = projectInviteService;
|
||||
this.organizationInviteService = organizationInviteService;
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
@Anyone
|
||||
@GetMapping("/users/@me")
|
||||
public ResponseEntity<?> getCurrentUser(HangarAuthenticationToken hangarAuthenticationToken) {
|
||||
public ResponseEntity<?> getCurrentUser(HangarAuthenticationToken hangarAuthenticationToken, @CookieValue(name = SecurityConfig.REFRESH_COOKIE_NAME, required = false) String refreshToken) {
|
||||
String token;
|
||||
String name;
|
||||
if (hangarAuthenticationToken == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
// if we don't have a token, lets see if we can get one via our refresh token
|
||||
if (refreshToken == null) {
|
||||
// neither token nor refresh token -> sorry no content
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
try {
|
||||
TokenService.RefreshResponse refreshResponse = tokenService.refreshAccessToken(refreshToken);
|
||||
token = refreshResponse.accessToken();
|
||||
name = refreshResponse.userTable().getName();
|
||||
} catch (HangarApiException ex) {
|
||||
// no token + no valid refresh token -> no content
|
||||
System.out.println("getCurrentUser failed: " + ex.getMessage());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
} else {
|
||||
// when we have a token, just use that
|
||||
token = hangarAuthenticationToken.getCredentials().getToken();
|
||||
name = hangarAuthenticationToken.getName();
|
||||
}
|
||||
return ResponseEntity.ok(usersApiService.getUser(hangarAuthenticationToken.getName(), HangarUser.class));
|
||||
// get user
|
||||
HangarUser user = usersApiService.getUser(name, HangarUser.class);
|
||||
user.setAccessToken(token);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
// @el(userName: String)
|
||||
|
@ -15,6 +15,7 @@ public class HangarUser extends User implements Identified {
|
||||
private final List<Integer> readPrompts;
|
||||
private final String language;
|
||||
private final String theme;
|
||||
private String accessToken;
|
||||
|
||||
public HangarUser(OffsetDateTime createdAt, String name, String tagline, OffsetDateTime joinDate, List<GlobalRole> roles, long projectCount, boolean locked, long id, List<Integer> readPrompts, String language, String theme) {
|
||||
super(createdAt, name, tagline, joinDate, roles, projectCount, locked);
|
||||
@ -49,6 +50,14 @@ public class HangarUser extends User implements Identified {
|
||||
return theme;
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public void setAccessToken(final String accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
public User toUser() {
|
||||
return new User(
|
||||
this.getCreatedAt(),
|
||||
|
@ -52,19 +52,18 @@ public class TokenService extends HangarComponent {
|
||||
return getVerifier().verify(token);
|
||||
}
|
||||
|
||||
public void issueRefreshAndAccessToken(UserTable userTable) {
|
||||
public void issueRefreshToken(UserTable userTable) {
|
||||
UserRefreshToken userRefreshToken = userRefreshTokenDAO.insert(new UserRefreshToken(userTable.getId(), UUID.randomUUID(), UUID.randomUUID()));
|
||||
addCookie(SecurityConfig.REFRESH_COOKIE_NAME, userRefreshToken.getToken().toString(), config.security.refreshTokenExpiry().toSeconds(), true);
|
||||
String accessToken = newToken0(userTable);
|
||||
// let the access token cookie be around for longer, so we can more nicely detect expired tokens via the response code
|
||||
addCookie(SecurityConfig.AUTH_NAME, accessToken, config.security.tokenExpiry().toSeconds() * 2, false);
|
||||
}
|
||||
|
||||
private void addCookie(String name, String value, long maxAge, boolean httpOnly) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, ResponseCookie.from(name, value).path("/").secure(config.security.secure()).maxAge(maxAge).sameSite("Lax").httpOnly(httpOnly).build().toString());
|
||||
}
|
||||
|
||||
public void refreshAccessToken(String refreshToken) {
|
||||
public record RefreshResponse(String accessToken, UserTable userTable) {}
|
||||
|
||||
public RefreshResponse refreshAccessToken(String refreshToken) {
|
||||
if (refreshToken == null) {
|
||||
throw new HangarApiException(299, "No refresh token found");
|
||||
}
|
||||
@ -90,8 +89,7 @@ public class TokenService extends HangarComponent {
|
||||
userRefreshToken = userRefreshTokenDAO.update(userRefreshToken);
|
||||
addCookie(SecurityConfig.REFRESH_COOKIE_NAME, userRefreshToken.getToken().toString(), config.security.refreshTokenExpiry().toSeconds(), true);
|
||||
// then issue a new access token
|
||||
String accessToken = newToken0(userTable);
|
||||
addCookie(SecurityConfig.AUTH_NAME, accessToken, config.security.tokenExpiry().toSeconds(), false);
|
||||
return new RefreshResponse(newToken0(userTable), userTable);
|
||||
}
|
||||
|
||||
public void invalidateToken(String refreshToken) {
|
||||
|
@ -2,13 +2,11 @@ package io.papermc.hangar.util;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Collections;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class RequestUtil {
|
||||
|
||||
@ -47,4 +45,8 @@ public class RequestUtil {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
public static String appendParam(String url, String paramName, String paramValue) {
|
||||
return UriComponentsBuilder.fromUriString(url).queryParam(paramName, paramValue).build().toUriString();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,17 @@
|
||||
package io.papermc.hangar.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.papermc.hangar.util.RequestUtil.appendParam;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RequestUtilTest {
|
||||
|
||||
@Test
|
||||
void test_appendParam() {
|
||||
assertEquals("http://localhost:8080?test=test", appendParam("http://localhost:8080", "test", "test"));
|
||||
assertEquals("http://localhost:8080/?test=test", appendParam("http://localhost:8080/", "test", "test"));
|
||||
assertEquals("http://localhost:8080/?dum=dum&test=test", appendParam("http://localhost:8080/?dum=dum", "test", "test"));
|
||||
assertEquals("http://localhost:8080/?dum&test=test", appendParam("http://localhost:8080/?dum", "test", "test"));
|
||||
}
|
||||
}
|
@ -1,23 +1,19 @@
|
||||
import type { AxiosError, AxiosRequestConfig } from "axios";
|
||||
import type { HangarApiException } from "hangar-api";
|
||||
import qs from "qs";
|
||||
import Cookies from "universal-cookie";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import type { Ref } from "vue";
|
||||
import { useAxios } from "~/composables/useAxios";
|
||||
import { useCookies } from "~/composables/useCookies";
|
||||
import { authLog, fetchLog } from "~/lib/composables/useLog";
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
import { useRequestEvent } from "#imports";
|
||||
import { useAxios } from "~/composables/useAxios";
|
||||
|
||||
function request<T>(url: string, method: AxiosRequestConfig["method"], data: object, headers: Record<string, string> = {}, retry = false): Promise<T> {
|
||||
function request<T>(url: string, method: AxiosRequestConfig["method"], data: object): Promise<T> {
|
||||
const cookies = useCookies();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
return useAxios
|
||||
return useAxios()
|
||||
.request<T>({
|
||||
method,
|
||||
url: `/api/${url}`,
|
||||
headers,
|
||||
data: method?.toLowerCase() !== "get" ? data : {},
|
||||
params: method?.toLowerCase() === "get" ? data : {},
|
||||
paramsSerializer: (params) => {
|
||||
@ -40,177 +36,24 @@ function request<T>(url: string, method: AxiosRequestConfig["method"], data: obj
|
||||
}
|
||||
resolve(data);
|
||||
})
|
||||
.catch(async (error: AxiosError) => {
|
||||
.catch((error: AxiosError) => {
|
||||
const { trace, ...err } = (error.response?.data as { trace: any }) || {};
|
||||
authLog("failed", err);
|
||||
// do we have an expired token?
|
||||
if (error.response?.status === 403) {
|
||||
if (retry) {
|
||||
// we failed on a retry, let's invalidate
|
||||
authLog("failed retry -> invalidate");
|
||||
await useAuth.invalidate();
|
||||
return reject(error);
|
||||
}
|
||||
// do we have a refresh token we could use?
|
||||
const result = await useAuth.refreshToken();
|
||||
if (result) {
|
||||
// retry
|
||||
authLog("Retrying request...");
|
||||
if (typeof result === "string") {
|
||||
headers = { ...headers, Authorization: `HangarAuth ${result}` };
|
||||
authLog("using new token");
|
||||
}
|
||||
try {
|
||||
const response = await request<T>(url, method, data, headers, true);
|
||||
return resolve(response);
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
} else {
|
||||
authLog("Not retrying since refresh failed");
|
||||
await useAuth.invalidate();
|
||||
return reject(error);
|
||||
}
|
||||
}
|
||||
authLog("failed", err, error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function useApi<T>(
|
||||
url: string,
|
||||
authed = true,
|
||||
method: AxiosRequestConfig["method"] = "get",
|
||||
data: object = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
export function useApi<T>(url: string, authed = true, method: AxiosRequestConfig["method"] = "get", data: object = {}): Promise<T> {
|
||||
fetchLog("useApi", url);
|
||||
return processAuthStuff(headers, authed, (headers) => request(`v1/${url}`, method, data, headers));
|
||||
return request(`v1/${url}`, method, data);
|
||||
}
|
||||
|
||||
export function useInternalApi<T = void>(
|
||||
url: string,
|
||||
authed = true,
|
||||
method: AxiosRequestConfig["method"] = "get",
|
||||
data: object = {},
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<T> {
|
||||
// TODO do something with authed
|
||||
|
||||
export function useInternalApi<T = void>(url: string, authed = true, method: AxiosRequestConfig["method"] = "get", data: object = {}): Promise<T> {
|
||||
fetchLog("useInternalApi", url);
|
||||
return processAuthStuff(headers, authed, (headers) => request(`internal/${url}`, method, data, headers));
|
||||
}
|
||||
|
||||
export async function processAuthStuff<T>(headers: Record<string, string>, authRequired: boolean, handler: (headers: Record<string, string>) => Promise<T>) {
|
||||
if (import.meta.env.SSR) {
|
||||
// forward cookies I guess?
|
||||
let token = useCookies().get("HangarAuth");
|
||||
let refreshToken = useCookies().get("HangarAuth_REFRESH");
|
||||
if (!token) {
|
||||
const header = useRequestEvent().node.res?.getHeader("set-cookie") as string[];
|
||||
if (header && header.join) {
|
||||
const cookies = new Cookies(header.join("; "));
|
||||
token = cookies.get("HangarAuth");
|
||||
refreshToken = cookies.get("HangarAuth_REFRESH");
|
||||
authLog("found token in set-cookie header");
|
||||
}
|
||||
}
|
||||
if (token || refreshToken) {
|
||||
authLog("forward token from cookie");
|
||||
|
||||
// make sure our token is still valid, else refresh
|
||||
if (!useAuth.validateToken(token)) {
|
||||
authLog("token no longer valid, lets refresh");
|
||||
const result = await useAuth.refreshToken();
|
||||
if (result) {
|
||||
authLog("refreshed");
|
||||
token = result;
|
||||
} else {
|
||||
authLog("could not refresh, invalidate");
|
||||
await useAuth.invalidate();
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
data: {
|
||||
isHangarApiException: true,
|
||||
httpError: {
|
||||
statusCode: 401,
|
||||
},
|
||||
message: "You must be logged in",
|
||||
} as HangarApiException,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
headers = { ...headers, Authorization: `HangarAuth ${token}`, ...forwardHeader() };
|
||||
} else {
|
||||
authLog("can't forward token, no cookie");
|
||||
if (authRequired) {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
data: {
|
||||
isHangarApiException: true,
|
||||
httpError: {
|
||||
statusCode: 401,
|
||||
},
|
||||
message: "You must be logged in",
|
||||
} as HangarApiException,
|
||||
},
|
||||
};
|
||||
}
|
||||
headers = { ...headers, ...forwardHeader() };
|
||||
}
|
||||
} else {
|
||||
// validate and refresh
|
||||
const token = useCookies().get("HangarAuth");
|
||||
if (!useAuth.validateToken(token)) {
|
||||
authLog("token no longer valid, lets refresh");
|
||||
const result = await useAuth.refreshToken();
|
||||
if (result) {
|
||||
authLog("refreshed");
|
||||
} else {
|
||||
authLog("could not refresh, invalidate");
|
||||
await useAuth.invalidate();
|
||||
if (authRequired) {
|
||||
// eslint-disable-next-line no-throw-literal
|
||||
throw {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
data: {
|
||||
isHangarApiException: true,
|
||||
httpError: {
|
||||
statusCode: 401,
|
||||
},
|
||||
message: "You must be logged in",
|
||||
} as HangarApiException,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler(headers);
|
||||
}
|
||||
|
||||
function forwardHeader(): Record<string, string> {
|
||||
const req = useRequestEvent().node.req;
|
||||
const result: Record<string, string> = {};
|
||||
if (!req) return result;
|
||||
|
||||
const forward = (header: string) => {
|
||||
if (req.headers[header]) {
|
||||
result[header] = req.headers[header];
|
||||
}
|
||||
};
|
||||
|
||||
forward("cf-connecting-ip");
|
||||
forward("cf-ipcountry");
|
||||
forward("x-forwarded-host");
|
||||
forward("x-real-ip");
|
||||
|
||||
return result;
|
||||
return request(`internal/${url}`, method, data);
|
||||
}
|
||||
|
||||
export async function fetchIfNeeded<T>(func: () => Promise<T>, ref: Ref<T>) {
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { HangarUser } from "hangar-internal";
|
||||
import { AxiosError, AxiosRequestHeaders } from "axios";
|
||||
import Cookies from "universal-cookie";
|
||||
import { AxiosError, AxiosInstance, AxiosRequestHeaders } from "axios";
|
||||
import jwtDecode, { type JwtPayload } from "jwt-decode";
|
||||
import { useAuthStore } from "~/store/auth";
|
||||
import { useCookies } from "~/composables/useCookies";
|
||||
import { useInternalApi } from "~/composables/useApi";
|
||||
import { useAxios } from "~/composables/useAxios";
|
||||
import { authLog } from "~/lib/composables/useLog";
|
||||
import { useConfig } from "~/lib/composables/useConfig";
|
||||
import { useRequestEvent } from "#imports";
|
||||
import { useAxios } from "~/composables/useAxios";
|
||||
|
||||
class Auth {
|
||||
loginUrl(redirectUrl: string): string {
|
||||
@ -22,8 +21,8 @@ class Auth {
|
||||
location.replace(`/logout?returnUrl=${useConfig().publicHost}?loggedOut`);
|
||||
}
|
||||
|
||||
validateToken(token: string) {
|
||||
if (!token) {
|
||||
validateToken(token: unknown): token is string {
|
||||
if (!token || typeof token !== "string") {
|
||||
return false;
|
||||
}
|
||||
const decoded = jwtDecode<JwtPayload>(token);
|
||||
@ -34,10 +33,10 @@ class Auth {
|
||||
}
|
||||
|
||||
// TODO do we need to scope this to the user?
|
||||
// TODO do we even need this anymore?
|
||||
refreshPromise: Promise<boolean | string> | null = null;
|
||||
refreshPromise: Promise<false | string> | null = null;
|
||||
|
||||
async refreshToken() {
|
||||
async refreshToken(): Promise<false | string> {
|
||||
// we use a promise as a lock here to make sure only one request is doing a refresh, avoids too many requests
|
||||
authLog("refresh token");
|
||||
if (this.refreshPromise) {
|
||||
authLog("locked, lets wait");
|
||||
@ -45,9 +44,8 @@ class Auth {
|
||||
authLog("lock over", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
this.refreshPromise = new Promise<boolean | string>(async (resolve) => {
|
||||
this.refreshPromise = new Promise<false | string>(async (resolve) => {
|
||||
const refreshToken = useCookies().get("HangarAuth_REFRESH");
|
||||
if (import.meta.env.SSR && !refreshToken) {
|
||||
authLog("no cookie, no point in refreshing");
|
||||
@ -63,28 +61,23 @@ class Auth {
|
||||
headers.cookie = "HangarAuth_REFRESH=" + refreshToken;
|
||||
authLog("pass refresh cookie", refreshToken);
|
||||
}
|
||||
const response = await useAxios.get("/refresh", { headers });
|
||||
const response = await useAxios().get("/refresh", { headers });
|
||||
if (response.status === 299) {
|
||||
authLog("had no cookie");
|
||||
resolve(false);
|
||||
} else if (import.meta.env.SSR) {
|
||||
if (response.headers["set-cookie"]) {
|
||||
} else if (response.status === 204) {
|
||||
// forward cookie header to renew refresh cookie
|
||||
if (import.meta.env.SSR && response.headers["set-cookie"]) {
|
||||
useRequestEvent().node.res?.setHeader("set-cookie", response.headers["set-cookie"]);
|
||||
const token = new Cookies(response.headers["set-cookie"]?.join("; ")).get("HangarAuth");
|
||||
if (token) {
|
||||
authLog("got token");
|
||||
resolve(token);
|
||||
} else {
|
||||
authLog("got no token in cookie header", response.headers["set-cookie"]);
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
// validate and return token
|
||||
const token = response.data;
|
||||
if (useAuth.validateToken(token)) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
authLog("got no cookie header back");
|
||||
authLog("refreshed token is not valid?", token);
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
authLog("done");
|
||||
resolve(true);
|
||||
}
|
||||
this.refreshPromise = null;
|
||||
} catch (e) {
|
||||
@ -101,38 +94,45 @@ class Auth {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async invalidate() {
|
||||
async invalidate(axios: AxiosInstance) {
|
||||
const store = useAuthStore();
|
||||
store.$patch({
|
||||
user: null,
|
||||
authenticated: false,
|
||||
token: null,
|
||||
});
|
||||
if (!store.invalidated) {
|
||||
await useAxios.get("/invalidate").catch((e) => authLog("Invalidate failed", e.message));
|
||||
}
|
||||
if (!import.meta.env.SSR) {
|
||||
useCookies().remove("HangarAuth_REFRESH", { path: "/" });
|
||||
useCookies().remove("HangarAuth", { path: "/" });
|
||||
authLog("Invalidated auth cookies");
|
||||
await axios.get("/invalidate").catch((e) => authLog("Invalidate failed", e.message));
|
||||
}
|
||||
store.invalidated = true;
|
||||
}
|
||||
|
||||
async updateUser(): Promise<void> {
|
||||
const authStore = useAuthStore();
|
||||
const axios = useAxios();
|
||||
if (authStore.invalidated) {
|
||||
authLog("no point in updating if we just invalidated");
|
||||
return;
|
||||
}
|
||||
if (authStore.user) {
|
||||
authLog("no point in updating if we already have a user");
|
||||
return;
|
||||
}
|
||||
const user = await useInternalApi<HangarUser>("users/@me", true).catch((err) => {
|
||||
authLog("no user", Object.assign({}, err));
|
||||
return this.invalidate();
|
||||
authLog("no user, with err", Object.assign({}, err));
|
||||
return this.invalidate(axios);
|
||||
});
|
||||
if (user) {
|
||||
authLog("patching " + user.name);
|
||||
authStore.setUser(user);
|
||||
authStore.$patch({ authenticated: true, invalidated: false });
|
||||
authStore.user = user;
|
||||
authStore.authenticated = true;
|
||||
authStore.invalidated = false;
|
||||
if (user.accessToken) {
|
||||
authStore.token = user.accessToken;
|
||||
}
|
||||
authLog("user is now " + authStore.user?.name);
|
||||
} else {
|
||||
authLog("no user, no content");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,6 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import axios from "axios";
|
||||
import { axiosLog } from "~/lib/composables/useLog";
|
||||
import { useConfig } from "~/lib/composables/useConfig";
|
||||
import { AxiosInstance } from "axios";
|
||||
import { useNuxtApp } from "#imports";
|
||||
|
||||
const config = useConfig();
|
||||
const options = {
|
||||
baseURL: import.meta.env.SSR ? config.proxyHost : config.publicHost,
|
||||
};
|
||||
axiosLog("axios options", options);
|
||||
export const useAxios: AxiosInstance = axios.create(options);
|
||||
export function useAxios() {
|
||||
return useNuxtApp().$axios as AxiosInstance;
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 5ceb57e631896689b945290997fbe58ec39940bf
|
||||
Subproject commit 54f6ba87a4f0664cf63f8132647ac558c324f8d4
|
@ -1,47 +1,28 @@
|
||||
import { RouteLocationNormalized, RouteLocationRaw } from "vue-router";
|
||||
import { PermissionCheck, UserPermissions } from "hangar-api";
|
||||
import { RouteLocationNamedRaw, RouteLocationNormalized } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { NuxtApp } from "nuxt/app";
|
||||
import { useAuth } from "~/composables/useAuth";
|
||||
import { routePermLog } from "~/lib/composables/useLog";
|
||||
import { PermissionCheck, UserPermissions } from "hangar-api";
|
||||
import { defineNuxtRouteMiddleware, handleRequestError, hasPerms, navigateTo, toNamedPermission, useApi, useAuth } from "#imports";
|
||||
import { useAuthStore } from "~/store/auth";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useErrorRedirect } from "~/lib/composables/useErrorRedirect";
|
||||
import { hasPerms, toNamedPermission } from "~/composables/usePerm";
|
||||
import { routePermLog } from "~/lib/composables/useLog";
|
||||
import { NamedPermission, PermissionType } from "~/types/enums";
|
||||
import { handleRequestError } from "~/composables/useErrorHandling";
|
||||
import { useSettingsStore } from "~/store/useSettingsStore";
|
||||
import { defineNuxtPlugin, useRequestEvent, useRouter } from "#imports";
|
||||
import { useErrorRedirect } from "~/lib/composables/useErrorRedirect";
|
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp: NuxtApp) => {
|
||||
useRouter().beforeEach(async (to, from, next) => {
|
||||
if (to.fullPath.startsWith("/@vite")) {
|
||||
// really don't need to do stuff for such meta routes
|
||||
return;
|
||||
}
|
||||
|
||||
await loadPerms(to);
|
||||
|
||||
const result = await handleRoutePerms(to);
|
||||
if (result) {
|
||||
next(result);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
await useAuth.updateUser();
|
||||
if (!process.server) return;
|
||||
const event = useRequestEvent();
|
||||
const request = event.node.res;
|
||||
const response = event.node.res;
|
||||
if (request?.url?.includes("/@vite")) {
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized, from: RouteLocationNormalized) => {
|
||||
if (to.fullPath.startsWith("/@vite")) {
|
||||
// really don't need to do stuff for such meta routes
|
||||
console.log("hit vite path???????????????????????", to.fullPath);
|
||||
return;
|
||||
}
|
||||
await useSettingsStore().loadSettingsServer(request, response);
|
||||
|
||||
await useAuth.updateUser();
|
||||
await loadRoutePerms(to);
|
||||
const result = await handleRoutePerms(to);
|
||||
if (result) {
|
||||
return navigateTo(result, { redirectCode: result.params?.status });
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPerms(to: RouteLocationNormalized) {
|
||||
async function loadRoutePerms(to: RouteLocationNormalized) {
|
||||
const authStore = useAuthStore();
|
||||
if (to.params.user && to.params.project) {
|
||||
if (authStore.authenticated) {
|
||||
@ -88,7 +69,7 @@ type handlersType = {
|
||||
[key: string]: (
|
||||
authStore: ReturnType<typeof useAuthStore>,
|
||||
to: RouteLocationNormalized
|
||||
) => Promise<RouteLocationRaw | undefined> | RouteLocationRaw | undefined;
|
||||
) => Promise<RouteLocationNamedRaw | undefined> | RouteLocationNamedRaw | undefined;
|
||||
};
|
||||
const handlers: handlersType = {
|
||||
currentUserRequired,
|
||||
@ -114,12 +95,13 @@ async function globalPermsRequired(authStore: ReturnType<typeof useAuthStore>, t
|
||||
routePermLog("route globalPermsRequired", to.meta.globalPermsRequired);
|
||||
const result = checkLogin(authStore, to, 403);
|
||||
if (result) return result;
|
||||
const i18n = useI18n();
|
||||
const check = await useApi<PermissionCheck>("permissions/hasAll", true, "get", {
|
||||
permissions: toNamedPermission(to.meta.globalPermsRequired as string[]),
|
||||
}).catch((e) => {
|
||||
try {
|
||||
routePermLog("error!", e);
|
||||
handleRequestError(e, useI18n());
|
||||
handleRequestError(e, i18n as ReturnType<typeof useI18n>); // dont ask me why I need this cast...
|
||||
} catch (e2) {
|
||||
routePermLog("error while checking perm", e);
|
||||
routePermLog("encountered additional error while error handling", e2);
|
12
frontend/src/middleware/settings.global.ts
Normal file
12
frontend/src/middleware/settings.global.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { RouteLocationNormalized } from "vue-router";
|
||||
import { defineNuxtRouteMiddleware, useRequestEvent } from "#imports";
|
||||
import { useSettingsStore } from "~/store/useSettingsStore";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to: RouteLocationNormalized, from: RouteLocationNormalized) => {
|
||||
if (!process.server || to.fullPath.includes("/@vite")) return;
|
||||
const event = useRequestEvent();
|
||||
const request = event.node.res;
|
||||
const response = event.node.res;
|
||||
|
||||
await useSettingsStore().loadSettingsServer(request, response);
|
||||
});
|
108
frontend/src/plugins/axios.ts
Normal file
108
frontend/src/plugins/axios.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { NuxtApp } from "nuxt/app";
|
||||
import { defineNuxtPlugin, useAuth, useRequestEvent } from "#imports";
|
||||
import { useAuthStore } from "~/store/auth";
|
||||
import { authLog, axiosLog } from "~/lib/composables/useLog";
|
||||
import { useConfig } from "~/lib/composables/useConfig";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp: NuxtApp) => {
|
||||
const config = useConfig();
|
||||
const options = {
|
||||
baseURL: import.meta.env.SSR ? config.proxyHost : config.publicHost,
|
||||
};
|
||||
axiosLog("axios options", options);
|
||||
const axiosInstance = axios.create(options);
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token;
|
||||
// forward auth token
|
||||
if (!config.headers) {
|
||||
config.headers = {};
|
||||
}
|
||||
if (token) {
|
||||
config.headers.Authorization = "HangarAuth " + token;
|
||||
}
|
||||
// forward other headers for ssr
|
||||
forwardRequestHeaders(config, nuxtApp);
|
||||
// axiosLog("calling with headers", config.headers);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(res) => {
|
||||
// forward cookies and stuff to browser
|
||||
forwardResponseHeaders(res, nuxtApp);
|
||||
return res;
|
||||
},
|
||||
async (err) => {
|
||||
const authStore = useAuthStore();
|
||||
const originalConfig = err.config;
|
||||
|
||||
axiosLog("got error", err);
|
||||
|
||||
if (originalConfig?.url !== "/refresh" && originalConfig?.url !== "/invalidate" && err.response) {
|
||||
// token expired
|
||||
if (err.response.status === 401 && !originalConfig._retry) {
|
||||
originalConfig._retry = true;
|
||||
|
||||
authLog("Request to", originalConfig.url, "failed with", err.response.status, "==> refreshing token");
|
||||
const refreshedToken = await useAuth.refreshToken();
|
||||
if (refreshedToken) {
|
||||
authStore.token = refreshedToken;
|
||||
return axiosInstance(originalConfig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
provide: {
|
||||
axios: axiosInstance,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function forwardRequestHeaders(config: AxiosRequestConfig, nuxtApp: NuxtApp) {
|
||||
if (!process.server) return;
|
||||
const req = useRequestEvent(nuxtApp).node.req;
|
||||
|
||||
const forward = (header: string) => {
|
||||
if (req.headers[header] && config.headers) {
|
||||
config.headers[header] = req.headers[header];
|
||||
}
|
||||
};
|
||||
|
||||
forward("cf-connecting-ip");
|
||||
forward("cf-ipcountry");
|
||||
forward("x-forwarded-host");
|
||||
forward("x-real-ip");
|
||||
forward("cookie");
|
||||
forward("sec-ch-prefers-color-scheme");
|
||||
forward("sec-ch-ua-mobile");
|
||||
forward("sec-ch-ua-platform");
|
||||
forward("sec-ch-ua");
|
||||
forward("user-agent");
|
||||
}
|
||||
|
||||
function forwardResponseHeaders(axiosResponse: AxiosResponse, nuxtApp: NuxtApp) {
|
||||
if (!process.server) return;
|
||||
const res = useRequestEvent(nuxtApp).node.res;
|
||||
|
||||
const forward = (header: string) => {
|
||||
if (axiosResponse.headers[header]) {
|
||||
res.setHeader(header, axiosResponse.headers[header]);
|
||||
}
|
||||
};
|
||||
|
||||
forward("set-cookie");
|
||||
forward("server");
|
||||
}
|
@ -4,6 +4,7 @@ import { ref } from "vue";
|
||||
import { authLog } from "~/lib/composables/useLog";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref<string | null>(null);
|
||||
const authenticated = ref<boolean>(false);
|
||||
const user = ref<HangarUser | null>(null);
|
||||
const routePermissions = ref<string | null>(null);
|
||||
@ -11,13 +12,9 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
authLog("create authStore");
|
||||
|
||||
function setUser(newUser: HangarUser) {
|
||||
user.value = newUser;
|
||||
}
|
||||
|
||||
function setRoutePerms(routePerms: string | null) {
|
||||
routePermissions.value = routePerms;
|
||||
}
|
||||
|
||||
return { authenticated, user, routePermissions, invalidated, setUser, setRoutePerms };
|
||||
return { token, authenticated, user, routePermissions, invalidated, setRoutePerms };
|
||||
});
|
||||
|
1
frontend/src/types/internal/users.d.ts
vendored
1
frontend/src/types/internal/users.d.ts
vendored
@ -42,6 +42,7 @@ declare module "hangar-internal" {
|
||||
readPrompts: number[];
|
||||
language: string;
|
||||
theme: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
interface UserTable extends Table, Named {
|
||||
|
Loading…
Reference in New Issue
Block a user