Update bface UI and security work
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
function lowerText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function arrayOfLower(values) {
|
||||
return Array.isArray(values) ? values.map(lowerText).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
export function getSessionClaims(securityState = {}) {
|
||||
return securityState?.session?.claims || {};
|
||||
}
|
||||
|
||||
export function getUserRoles(securityState = {}) {
|
||||
const userRoles = arrayOfLower(securityState?.user?.role_names);
|
||||
const claimRoles = arrayOfLower(getSessionClaims(securityState).roles);
|
||||
return Array.from(new Set([...userRoles, ...claimRoles]));
|
||||
}
|
||||
|
||||
export function isAdminUser(securityState = {}) {
|
||||
return Boolean(securityState?.user?.is_admin || getSessionClaims(securityState).is_admin);
|
||||
}
|
||||
|
||||
export function hasRequiredProducts(securityState = {}, requiredProducts = []) {
|
||||
if (!Array.isArray(requiredProducts) || requiredProducts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const granted = arrayOfLower(getSessionClaims(securityState).products);
|
||||
if (granted.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredProducts.some((product) => granted.includes(lowerText(product)));
|
||||
}
|
||||
|
||||
export function hasRequiredClaims(securityState = {}, requiredClaims = null) {
|
||||
if (!requiredClaims || typeof requiredClaims !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const claims = getSessionClaims(securityState);
|
||||
return Object.entries(requiredClaims).every(([key, expected]) => {
|
||||
const actual = claims?.[key];
|
||||
if (Array.isArray(expected)) {
|
||||
const expectedSet = arrayOfLower(expected);
|
||||
const actualSet = arrayOfLower(Array.isArray(actual) ? actual : [actual]);
|
||||
return expectedSet.every((value) => actualSet.includes(value));
|
||||
}
|
||||
return actual === expected;
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateAuthRequirements(securityState = {}, requirements = {}) {
|
||||
const requireUser = Boolean(requirements.require_user);
|
||||
const requireAdmin = Boolean(requirements.require_admin);
|
||||
const requiredRoles = arrayOfLower(requirements.required_roles);
|
||||
const requiredProducts = requirements.required_products || [];
|
||||
const requiredClaims = requirements.required_claims || null;
|
||||
|
||||
if (requireUser && !securityState?.isAuthenticated) {
|
||||
return { allowed: false, requires_login: true, reason: 'Login required' };
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdminUser(securityState)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Administrator access required' };
|
||||
}
|
||||
|
||||
if (requiredRoles.length > 0) {
|
||||
const grantedRoles = getUserRoles(securityState);
|
||||
if (!requiredRoles.some((role) => grantedRoles.includes(role))) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required role missing' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRequiredProducts(securityState, requiredProducts)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required product access missing' };
|
||||
}
|
||||
|
||||
if (!hasRequiredClaims(securityState, requiredClaims)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required token claims missing' };
|
||||
}
|
||||
|
||||
return { allowed: true, requires_login: false, reason: 'Auth requirements satisfied' };
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { evaluateAuthRequirements } from './access-rules.js';
|
||||
|
||||
export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
const securityState = securityService.getState();
|
||||
const options = route?.options || {};
|
||||
const resourcePath = options.resource_path || route?.path || '/';
|
||||
const requestedRights = normalizeRightsInput(options.required_rights || 0);
|
||||
const authRequirements = {
|
||||
require_user: options.require_user,
|
||||
require_admin: options.require_admin,
|
||||
required_roles: options.required_roles,
|
||||
required_products: options.required_products,
|
||||
required_claims: options.required_claims,
|
||||
};
|
||||
|
||||
if (!securityState.enabled) {
|
||||
return {
|
||||
@@ -14,7 +22,14 @@ export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.require_user && requestedRights === 0) {
|
||||
const needsAuthCheck = Object.values(authRequirements).some((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
});
|
||||
|
||||
if (!needsAuthCheck && requestedRights === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
@@ -22,14 +37,22 @@ export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
};
|
||||
}
|
||||
|
||||
if (options.require_user && !securityState.isAuthenticated) {
|
||||
if (!securityState.initialized || securityState.loading) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Login required for route'
|
||||
requires_login: false,
|
||||
pending: true,
|
||||
reason: 'Security state is still initializing'
|
||||
};
|
||||
}
|
||||
|
||||
if (needsAuthCheck) {
|
||||
const authResult = evaluateAuthRequirements(securityState, authRequirements);
|
||||
if (!authResult.allowed) {
|
||||
return authResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedRights !== 0) {
|
||||
return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { setRouterPath } from '../../platform/compat.js';
|
||||
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { ApiSecurityPolicy, BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
|
||||
|
||||
const DEFAULT_SECURITY_CONFIG = {
|
||||
@@ -44,6 +44,9 @@ class SecurityService {
|
||||
this.state = createInitialState();
|
||||
this.listeners = new Set();
|
||||
this.apiHooksInstalled = false;
|
||||
this.initPromise = null;
|
||||
this.registeredResourceKeys = new Set();
|
||||
this.resourceRegistrationPromises = new Map();
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
@@ -73,6 +76,9 @@ class SecurityService {
|
||||
}
|
||||
|
||||
_resolvePolicy(config) {
|
||||
if (config.provider === 'api') {
|
||||
return new ApiSecurityPolicy(config);
|
||||
}
|
||||
if (config.provider === 'bstore') {
|
||||
return new BstoreSecurityPolicy(config);
|
||||
}
|
||||
@@ -81,70 +87,87 @@ class SecurityService {
|
||||
|
||||
async init(config = {}) {
|
||||
const normalizedConfig = normalizeSecurityConfig(config);
|
||||
|
||||
if (!normalizedConfig.enabled) {
|
||||
this.setState({
|
||||
...createInitialState(),
|
||||
initialized: true,
|
||||
config: normalizedConfig,
|
||||
enabled: false,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
error: null,
|
||||
enabled: true,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig
|
||||
});
|
||||
|
||||
try {
|
||||
const policy = this._resolvePolicy(normalizedConfig);
|
||||
await policy.init();
|
||||
const session = await policy.getCurrentSession();
|
||||
const user = session?.user_id ? await policy.getUser(session.user_id) : null;
|
||||
const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null;
|
||||
const profile = user ? await policy.getAccountProfile(user.id) : null;
|
||||
const initTask = (async () => {
|
||||
if (!normalizedConfig.enabled) {
|
||||
this.setState({
|
||||
...createInitialState(),
|
||||
initialized: true,
|
||||
config: normalizedConfig,
|
||||
enabled: false,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
enabled: true,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig,
|
||||
policy,
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated: Boolean(session && user),
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to initialize security service:', error);
|
||||
this.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
enabled: normalizedConfig.enabled,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig,
|
||||
policy: null,
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
isAuthenticated: false,
|
||||
error
|
||||
});
|
||||
|
||||
try {
|
||||
const policy = this._resolvePolicy(normalizedConfig);
|
||||
await policy.init();
|
||||
const session = await policy.getCurrentSession();
|
||||
const user = session?.user_id ? await policy.getUser(session.user_id) : null;
|
||||
const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null;
|
||||
const profile = user ? await policy.getAccountProfile(user.id) : null;
|
||||
|
||||
this.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
enabled: true,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig,
|
||||
policy,
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated: Boolean(session && user),
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Security] Failed to initialize security service:', error);
|
||||
this.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
enabled: normalizedConfig.enabled,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig,
|
||||
policy: null,
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
isAuthenticated: false,
|
||||
error
|
||||
});
|
||||
}
|
||||
|
||||
return this.state;
|
||||
})();
|
||||
this.initPromise = initTask;
|
||||
|
||||
return initTask;
|
||||
}
|
||||
|
||||
async waitUntilInitialized() {
|
||||
if (this.state.initialized || !this.initPromise) {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initPromise;
|
||||
} catch {
|
||||
// State already captures the initialization failure.
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -161,12 +184,14 @@ class SecurityService {
|
||||
if (!this.state.session?.jwt_token) {
|
||||
return config;
|
||||
}
|
||||
const incomingHeaders = config.headers;
|
||||
const headers = incomingHeaders instanceof Headers
|
||||
? new Headers(incomingHeaders)
|
||||
: new Headers(incomingHeaders || {});
|
||||
headers.set('Authorization', `Bearer ${this.state.session.jwt_token}`);
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers || {}),
|
||||
Authorization: `Bearer ${this.state.session.jwt_token}`
|
||||
}
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +201,10 @@ class SecurityService {
|
||||
|
||||
async handleUnauthorizedResponse() {
|
||||
if (this.state.isAuthenticated) {
|
||||
await this.logout({ redirect: true });
|
||||
const currentPath = await getRouterPath('/home');
|
||||
const loginRoute = this.state.config.login_route || '/login';
|
||||
const redirectTo = currentPath && currentPath !== loginRoute ? currentPath : null;
|
||||
await this.logout({ redirect: true, redirectTo });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +237,7 @@ class SecurityService {
|
||||
}
|
||||
|
||||
async logout(options = {}) {
|
||||
const { redirect = true } = options;
|
||||
const { redirect = true, redirectTo = null } = options;
|
||||
|
||||
if (this.state.policy && this.state.session) {
|
||||
try {
|
||||
@@ -233,7 +261,9 @@ class SecurityService {
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true);
|
||||
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true, {
|
||||
state: redirectTo ? { redirect_to: redirectTo } : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +289,40 @@ class SecurityService {
|
||||
}
|
||||
|
||||
async registerResource(resource) {
|
||||
if (this.state.enabled && !this.state.initialized) {
|
||||
await this.waitUntilInitialized();
|
||||
}
|
||||
if (!this.state.policy) {
|
||||
return null;
|
||||
}
|
||||
return this.state.policy.registerResource(resource);
|
||||
if (this.state.provider === 'api' && !this.state.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
const resourcePath = String(resource?.path || '').trim();
|
||||
const resourceType = String(resource?.type || '').trim();
|
||||
if (!resourcePath) {
|
||||
return null;
|
||||
}
|
||||
const key = `${resourceType}:${resourcePath}`;
|
||||
if (this.registeredResourceKeys.has(key)) {
|
||||
return null;
|
||||
}
|
||||
if (this.resourceRegistrationPromises.has(key)) {
|
||||
return this.resourceRegistrationPromises.get(key);
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const registered = await this.state.policy.registerResource(resource);
|
||||
this.registeredResourceKeys.add(key);
|
||||
return registered;
|
||||
} finally {
|
||||
this.resourceRegistrationPromises.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
this.resourceRegistrationPromises.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async userRequired(options = {}) {
|
||||
@@ -275,7 +335,10 @@ class SecurityService {
|
||||
}
|
||||
|
||||
if (options.redirect !== false) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
|
||||
return { allowed: false, requires_login: true, reason: 'User login required' };
|
||||
@@ -289,7 +352,10 @@ class SecurityService {
|
||||
if (!this.state.isAuthenticated) {
|
||||
const response = { allowed: false, requires_login: true, reason: 'User login required', matched_permits: [] };
|
||||
if (options.redirectOnFail) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -302,7 +368,10 @@ class SecurityService {
|
||||
);
|
||||
|
||||
if (!result.allowed && result.requires_login && options.redirectOnFail) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -351,11 +420,44 @@ class SecurityService {
|
||||
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
||||
}
|
||||
|
||||
async uploadAccountAvatar(file) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
|
||||
throw new Error('Avatar upload is not available');
|
||||
}
|
||||
const profile = await this.state.policy.uploadAccountAvatar(this.state.user.id, file);
|
||||
const user = await this.state.policy.getUser(this.state.user.id);
|
||||
this.setState({
|
||||
profile,
|
||||
user
|
||||
});
|
||||
return profile;
|
||||
}
|
||||
|
||||
async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; }
|
||||
async createUser(userData) { return this.state.policy ? this.state.policy.createUser(userData) : null; }
|
||||
async updateUser(userId, patch) { return this.state.policy ? this.state.policy.updateUser(userId, patch) : null; }
|
||||
async deleteUser(userId) { return this.state.policy ? this.state.policy.deleteUser(userId) : null; }
|
||||
async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; }
|
||||
async listSubjects() { return this.state.policy ? this.state.policy.listSubjects() : []; }
|
||||
async createRole(roleData) { return this.state.policy ? this.state.policy.createRole(roleData) : null; }
|
||||
async updateRole(roleId, patch) { return this.state.policy ? this.state.policy.updateRole(roleId, patch) : null; }
|
||||
async deleteRole(roleId) { return this.state.policy ? this.state.policy.deleteRole(roleId) : null; }
|
||||
async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; }
|
||||
async createRealm(realmData) { return this.state.policy ? this.state.policy.createRealm(realmData) : null; }
|
||||
async updateRealm(realmId, patch) { return this.state.policy ? this.state.policy.updateRealm(realmId, patch) : null; }
|
||||
async deleteRealm(realmId) { return this.state.policy ? this.state.policy.deleteRealm(realmId) : null; }
|
||||
async listResources() { return this.state.policy ? this.state.policy.listResources() : []; }
|
||||
async createResource(resource) { return this.state.policy ? this.state.policy.registerResource(resource) : null; }
|
||||
async updateResource(path, patch) { return this.state.policy ? this.state.policy.updateResource(path, patch) : null; }
|
||||
async deleteResource(path) {
|
||||
return this.state.policy && typeof this.state.policy.deleteResource === 'function'
|
||||
? this.state.policy.deleteResource(path)
|
||||
: null;
|
||||
}
|
||||
async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; }
|
||||
async createPermit(permit) { return this.state.policy ? this.state.policy.grantPermit(permit) : null; }
|
||||
async updatePermit(permitId, patch) { return this.state.policy ? this.state.policy.updatePermit(permitId, patch) : null; }
|
||||
async deletePermit(permitId) { return this.state.policy ? this.state.policy.revokePermit(permitId) : null; }
|
||||
}
|
||||
|
||||
export const securityService = new SecurityService();
|
||||
|
||||
Reference in New Issue
Block a user