This commit is contained in:
+63
-11
@@ -9,9 +9,10 @@ import passport from 'passport';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import type { AuthConfig } from '../config.js';
|
||||
import type { AuthConfig, AuthProviderConfig } from '../config.js';
|
||||
import type { Repository } from '../db/repository.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* WebSocket upgrade(生 IncomingMessage)から認証済みユーザーを解決するチェッカー。
|
||||
@@ -43,6 +44,36 @@ function escapeHtml(s: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider is usable only when ALL fields needed to complete an OAuth round
|
||||
* trip are present (Gitea additionally needs base_url). Used to gate auth
|
||||
* activation and login-button visibility so a partial config saved from the
|
||||
* Settings UI can't enable auth in a state where nobody can log in.
|
||||
*/
|
||||
export function isProviderConfigured(
|
||||
p: AuthProviderConfig | undefined,
|
||||
kind: 'google' | 'gitea',
|
||||
): p is AuthProviderConfig {
|
||||
if (!p || !p.clientId || !p.clientSecret || !p.callbackUrl) return false;
|
||||
if (kind === 'gitea' && !p.baseUrl) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a provider should actually be LIVE (strategy + /auth/<kind> route).
|
||||
* A valid `primaryProvider` restricts auth to that single provider, so the
|
||||
* restriction is enforced at the route layer too — not just hidden on the login
|
||||
* page (otherwise the "disabled" provider's route stayed open to direct URLs).
|
||||
* An invalid primary (pointing at an unconfigured provider) is ignored.
|
||||
*/
|
||||
export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean {
|
||||
if (!isProviderConfigured(authConfig.providers[kind], kind)) return false;
|
||||
const primary = authConfig.primaryProvider;
|
||||
if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google';
|
||||
if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea';
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* auth-login.html をレンダリングする。
|
||||
* primary_provider 設定と各プロバイダの configured 状態に応じて
|
||||
@@ -51,9 +82,15 @@ function escapeHtml(s: string): string {
|
||||
*/
|
||||
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
|
||||
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
|
||||
const primary = authConfig.primaryProvider;
|
||||
const googleConfigured = !!authConfig.providers.google?.clientId;
|
||||
const giteaConfigured = !!authConfig.providers.gitea?.clientId;
|
||||
const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google');
|
||||
const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea');
|
||||
// Ignore a primaryProvider that points to an unconfigured provider — otherwise
|
||||
// it would hide the only working login button and lock the operator out.
|
||||
const primary =
|
||||
(authConfig.primaryProvider === 'google' && googleConfigured) ||
|
||||
(authConfig.primaryProvider === 'gitea' && giteaConfigured)
|
||||
? authConfig.primaryProvider
|
||||
: undefined;
|
||||
|
||||
// Decide which buttons to show
|
||||
let showGoogle: boolean;
|
||||
@@ -315,7 +352,8 @@ export async function fetchGiteaOrgsForUser(
|
||||
|
||||
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
const googleConfig = authConfig.providers.google;
|
||||
if (!googleConfig) return;
|
||||
if (!isProviderConfigured(googleConfig, 'google')) return;
|
||||
if (!isProviderActive(authConfig, 'google')) return;
|
||||
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
@@ -331,7 +369,7 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void
|
||||
|
||||
await handleOAuthCallback(
|
||||
repo,
|
||||
authConfig.adminEmails,
|
||||
authConfig.adminEmails ?? [],
|
||||
'google',
|
||||
profile.id,
|
||||
email,
|
||||
@@ -346,7 +384,8 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void
|
||||
|
||||
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
const giteaConfig = authConfig.providers.gitea;
|
||||
if (!giteaConfig) return;
|
||||
if (!isProviderConfigured(giteaConfig, 'gitea')) return;
|
||||
if (!isProviderActive(authConfig, 'gitea')) return;
|
||||
|
||||
const baseUrl = giteaConfig.baseUrl ?? '';
|
||||
|
||||
@@ -401,7 +440,7 @@ function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||
name,
|
||||
avatarUrl,
|
||||
});
|
||||
if (user.status === 'pending' && authConfig.adminEmails.includes(email)) {
|
||||
if (user.status === 'pending' && (authConfig.adminEmails ?? []).includes(email)) {
|
||||
repo.updateUser(user.id, { status: 'active', role: 'admin' });
|
||||
const updated = repo.getUserById(user.id);
|
||||
if (updated) user = updated;
|
||||
@@ -457,7 +496,7 @@ function createAuthRouter(
|
||||
});
|
||||
|
||||
// Google OAuth
|
||||
if (authConfig.providers.google) {
|
||||
if (isProviderActive(authConfig, 'google')) {
|
||||
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
||||
|
||||
router.get(
|
||||
@@ -475,7 +514,7 @@ function createAuthRouter(
|
||||
}
|
||||
|
||||
// Gitea OAuth
|
||||
if (authConfig.providers.gitea) {
|
||||
if (isProviderActive(authConfig, 'gitea')) {
|
||||
router.get('/gitea', passport.authenticate('gitea'));
|
||||
|
||||
router.get(
|
||||
@@ -533,9 +572,22 @@ export function setupAuth(
|
||||
): AuthMiddlewares {
|
||||
const db = repo.getDb();
|
||||
|
||||
// express-session throws "secret option required" (→ 500 on every request) if
|
||||
// the secret is empty. Auth can be enabled from the Settings UI before a
|
||||
// session_secret is set, so fall back to a random per-process secret with a
|
||||
// warning rather than bricking the server. Sessions reset on restart until a
|
||||
// stable value is configured.
|
||||
let sessionSecret = authConfig.sessionSecret;
|
||||
if (!sessionSecret || sessionSecret.length === 0) {
|
||||
sessionSecret = randomBytes(32).toString('hex');
|
||||
logger.warn(
|
||||
'[auth] auth.session_secret is unset — using a random per-process secret; sessions reset on restart. Set a stable value in Settings → Authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
// セッションミドルウェア
|
||||
const sessionMiddleware = session({
|
||||
secret: authConfig.sessionSecret,
|
||||
secret: sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: createSqliteSessionStore(db),
|
||||
|
||||
Reference in New Issue
Block a user