feat: Oracle IAM OIDC, force password change, security hardening
- OIDC: authorization code flow, JWKS validation, JIT provisioning, group-to-role mapping, dual auth (local/oidc/both) - Force password change: random admin password on first install, modal blocks UI until changed - APP_SECRET required in docker-compose (fail if missing), .env.example improved - Login page: Oracle IAM button (conditional), hide form in oidc-only mode - OracleIamPage: test connection via backend, masked client_secret handling - UsersPage: OIDC badge, auth_provider in list - i18n: login.oidcButton, login.or (pt/en/es) - README v3.1: Terminal, User Management, Security, OIDC endpoints, versioning
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useEffect, lazy, Suspense } from 'react';
|
||||
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { authApi } from '@/api/endpoints/auth';
|
||||
import AppShell from '@/components/layout/AppShell';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
|
||||
// Lazy-loaded pages (code splitting)
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
@@ -34,10 +35,72 @@ function PageLoader() {
|
||||
);
|
||||
}
|
||||
|
||||
function ForcePasswordModal() {
|
||||
const [curPw, setCurPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const clearMustChangePassword = useAuthStore((s) => s.clearMustChangePassword);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPw.length < 8) { setError('A nova senha deve ter no minimo 8 caracteres'); return; }
|
||||
if (newPw !== confirmPw) { setError('As senhas nao coincidem'); return; }
|
||||
if (newPw === curPw) { setError('A nova senha deve ser diferente da atual'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.changePassword(curPw, newPw);
|
||||
clearMustChangePassword();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao alterar senha');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(4px)' }}>
|
||||
<div className="w-[400px] p-8 rounded-2xl" style={{ background: 'var(--bg1)', boxShadow: 'var(--sh3)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'var(--rdl)' }}>
|
||||
<Lock size={24} style={{ color: 'var(--rd)' }} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--t1)' }}>Alterar Senha</h2>
|
||||
<p className="text-xs mt-1 text-center" style={{ color: 'var(--t3)' }}>
|
||||
Por seguranca, altere sua senha antes de continuar.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input type="password" placeholder="Senha atual" value={curPw} onChange={e => setCurPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} autoFocus />
|
||||
<input type="password" placeholder="Nova senha (min. 8 caracteres)" value={newPw} onChange={e => setNewPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
<input type="password" placeholder="Confirmar nova senha" value={confirmPw} onChange={e => setConfirmPw(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }} />
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>{error}</div>
|
||||
)}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}>
|
||||
{loading ? 'Salvando...' : 'Alterar Senha'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const checkAuth = useAuthStore((s) => s.checkAuth);
|
||||
const loadData = useAppStore((s) => s.loadData);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth().then(() => {
|
||||
@@ -51,6 +114,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
{user && mustChangePassword && <ForcePasswordModal />}
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<AppShell />}>
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
mfa_enabled?: boolean;
|
||||
timezone?: string;
|
||||
auth_provider?: string;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -18,6 +19,7 @@ export interface LoginResponse {
|
||||
mfa_required?: boolean;
|
||||
mfa_setup?: boolean;
|
||||
totp_uri?: string;
|
||||
must_change_password?: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
@@ -26,6 +28,9 @@ export const authApi = {
|
||||
|
||||
logout: () => client.post('/auth/logout'),
|
||||
|
||||
oidcLogout: () =>
|
||||
client.post<never, { ok: boolean; idp_logout_url?: string }>('/auth/oidc/logout'),
|
||||
|
||||
me: () => client.get<never, User>('/users/me'),
|
||||
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
|
||||
@@ -45,6 +45,8 @@ const en: Record<string, string> = {
|
||||
'login.verify': 'Verify',
|
||||
'login.submit': 'Sign in',
|
||||
'login.error': 'Login failed',
|
||||
'login.or': 'or',
|
||||
'login.oidcButton': 'Sign in with Oracle IAM',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'now',
|
||||
|
||||
@@ -45,6 +45,8 @@ const es: Record<string, string> = {
|
||||
'login.verify': 'Verificar',
|
||||
'login.submit': 'Iniciar sesion',
|
||||
'login.error': 'Error de inicio de sesion',
|
||||
'login.or': 'o',
|
||||
'login.oidcButton': 'Iniciar sesion con Oracle IAM',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'ahora',
|
||||
|
||||
@@ -45,6 +45,8 @@ const pt: Record<string, string> = {
|
||||
'login.verify': 'Verificar',
|
||||
'login.submit': 'Entrar',
|
||||
'login.error': 'Erro ao fazer login',
|
||||
'login.or': 'ou',
|
||||
'login.oidcButton': 'Login com Oracle IAM',
|
||||
|
||||
// ── Chat ──
|
||||
'chat.timeNow': 'agora',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
@@ -15,6 +16,11 @@ export default function LoginPage() {
|
||||
const [totp, setTotp] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authMode, setAuthMode] = useState('local');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/oidc/config').then(r => r.json()).then(d => setAuthMode(d.auth_mode || 'local')).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (user) {
|
||||
navigate('/chat', { replace: true });
|
||||
@@ -57,71 +63,94 @@ export default function LoginPage() {
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--t3)' }}>{t('login.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mfaSetup && totpUri && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{authMode !== 'oidc' && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
{!mfaRequired ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.userPlaceholder')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t('login.passPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{mfaSetup && totpUri && (
|
||||
<div className="text-center mb-2">
|
||||
<p className="text-xs mb-2" style={{ color: 'var(--t3)' }}>
|
||||
{t('login.mfaScanQr')}
|
||||
</p>
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpUri)}`}
|
||||
alt="QR Code"
|
||||
className="mx-auto rounded-lg"
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('login.totpPlaceholder')}
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-lg text-sm outline-none text-center tracking-[.3em]"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', fontFamily: 'var(--fm)' }}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs px-3 py-2 rounded-lg" style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}
|
||||
>
|
||||
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold text-white transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--ac)' }}
|
||||
>
|
||||
{loading ? t('login.loading') : mfaRequired ? t('login.verify') : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{(authMode === 'oidc' || authMode === 'both') && (
|
||||
<>
|
||||
{authMode === 'both' && (
|
||||
<div className="flex items-center gap-2 my-3">
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
<span className="text-[.65rem]" style={{ color: 'var(--t4)' }}>{t('login.or')}</span>
|
||||
<div className="flex-1 h-px" style={{ background: 'var(--bd)' }} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)', border: '1px solid color-mix(in srgb, var(--rd) 30%, transparent)' }}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{t('login.oidcButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,9 +65,8 @@ export default function OracleIamPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
SETTINGS_KEYS.map(k =>
|
||||
client.put(`/settings/${k}`, { value: (config as any)[k] || '' })
|
||||
)
|
||||
SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022')))
|
||||
.map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' }))
|
||||
);
|
||||
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
|
||||
} catch (err) {
|
||||
@@ -84,12 +83,12 @@ export default function OracleIamPage() {
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const url = config.oidc_issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const disco = await resp.json();
|
||||
if (disco.authorization_endpoint && disco.token_endpoint) {
|
||||
setMsg({ text: t('iam.testOk') || `Conexão OK — ${disco.issuer}`, type: 'success' });
|
||||
const resp = await client.post('/settings/oidc/test') as unknown as {
|
||||
ok: boolean; issuer: string;
|
||||
authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean;
|
||||
};
|
||||
if (resp.ok) {
|
||||
setMsg({ text: `${t('iam.testOk') || 'Conexão OK'} — ${resp.issuer}`, type: 'success' });
|
||||
} else {
|
||||
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface AppUser {
|
||||
is_active: boolean | number;
|
||||
created_at: string | null;
|
||||
last_login: string | null;
|
||||
auth_provider?: string;
|
||||
}
|
||||
|
||||
type Role = 'admin' | 'user' | 'viewer';
|
||||
@@ -409,6 +410,10 @@ export default function UsersPage() {
|
||||
<span className="text-xs" style={{ color: 'var(--t2)', fontFamily: 'var(--fm)' }}>
|
||||
{u.username}
|
||||
</span>
|
||||
{u.auth_provider === 'oidc' && (
|
||||
<span className="ml-1.5 text-[.55rem] font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--rdl)', color: 'var(--rd)' }}>OIDC</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
|
||||
@@ -8,11 +8,13 @@ interface AuthState {
|
||||
mfaRequired: boolean;
|
||||
mfaSetup: boolean;
|
||||
totpUri: string | null;
|
||||
mustChangePassword: boolean;
|
||||
|
||||
login: (username: string, password: string, totp?: string) => Promise<LoginResponse>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setToken: (token: string) => void;
|
||||
clearMustChangePassword: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@@ -22,6 +24,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
mfaRequired: false,
|
||||
mfaSetup: false,
|
||||
totpUri: null,
|
||||
mustChangePassword: false,
|
||||
|
||||
login: async (username, password, totp) => {
|
||||
const res = await authApi.login(username, password, totp);
|
||||
@@ -31,15 +34,26 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
if (res.token && res.user) {
|
||||
localStorage.setItem('t', res.token);
|
||||
set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null });
|
||||
set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null,
|
||||
mustChangePassword: !!res.must_change_password });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try { await authApi.logout(); } catch {}
|
||||
const currentUser = get().user;
|
||||
try {
|
||||
if (currentUser?.auth_provider === 'oidc') {
|
||||
const res = await authApi.oidcLogout();
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
|
||||
if (res.idp_logout_url) window.location.href = res.idp_logout_url;
|
||||
return;
|
||||
}
|
||||
await authApi.logout();
|
||||
} catch {}
|
||||
localStorage.removeItem('t');
|
||||
set({ user: null, token: null, mfaRequired: false });
|
||||
set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
@@ -47,7 +61,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (!token) { set({ loading: false }); return; }
|
||||
try {
|
||||
const user = await authApi.me();
|
||||
set({ user, loading: false });
|
||||
set({ user, loading: false, mustChangePassword: !!user.must_change_password });
|
||||
} catch {
|
||||
localStorage.removeItem('t');
|
||||
set({ token: null, user: null, loading: false });
|
||||
@@ -58,4 +72,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
localStorage.setItem('t', token);
|
||||
set({ token });
|
||||
},
|
||||
|
||||
clearMustChangePassword: () => set({ mustChangePassword: false }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user