diff --git a/server/database/db.js b/server/database/db.js index 6fc13477..5d31b41a 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -39,11 +39,11 @@ const userDb = { }, // Create a new user - createUser: (username, passwordHash) => { + createUser: (username, passwordHash, isPamUser = false) => { try { - const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); - const result = stmt.run(username, passwordHash); - return { id: result.lastInsertRowid, username }; + const stmt = db.prepare('INSERT INTO users (username, password_hash, is_pam_user) VALUES (?, ?, ?)'); + const result = stmt.run(username, passwordHash, isPamUser ? 1 : 0); + return { id: result.lastInsertRowid, username, is_pam_user: isPamUser }; } catch (err) { throw err; } @@ -71,11 +71,31 @@ const userDb = { // Get user by ID getUserById: (userId) => { try { - const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId); + const row = db.prepare('SELECT id, username, created_at, last_login, is_pam_user FROM users WHERE id = ? AND is_active = 1').get(userId); return row; } catch (err) { throw err; } + }, + + // Update user PAM status + updateUserPamStatus: (userId, isPamUser) => { + try { + const stmt = db.prepare('UPDATE users SET is_pam_user = ? WHERE id = ?'); + stmt.run(isPamUser ? 1 : 0, userId); + } catch (err) { + throw err; + } + }, + + // Check if user is PAM user + isPamUser: (userId) => { + try { + const row = db.prepare('SELECT is_pam_user FROM users WHERE id = ? AND is_active = 1').get(userId); + return row ? row.is_pam_user === 1 : false; + } catch (err) { + throw err; + } } }; diff --git a/server/database/init.sql b/server/database/init.sql index bf007b96..9cb07d1c 100644 --- a/server/database/init.sql +++ b/server/database/init.sql @@ -5,7 +5,8 @@ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, + password_hash TEXT, -- Can be null for PAM users + is_pam_user BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME, is_active BOOLEAN DEFAULT 1 diff --git a/server/routes/auth.js b/server/routes/auth.js index 82a7c0d8..d83a7f97 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -2,6 +2,7 @@ import express from 'express'; import bcrypt from 'bcrypt'; import { userDb, db } from '../database/db.js'; import { generateToken, authenticateToken } from '../middleware/auth.js'; +import pamAuth from '../services/pamAuth.js'; const router = express.Router(); @@ -9,8 +10,10 @@ const router = express.Router(); router.get('/status', async (req, res) => { try { const hasUsers = await userDb.hasUsers(); - res.json({ + const pamAvailable = await pamAuth.isAvailable(); + res.json({ needsSetup: !hasUsers, + pamAvailable, isAuthenticated: false // Will be overridden by frontend if token exists }); } catch (error) { @@ -125,6 +128,80 @@ router.get('/user', authenticateToken, (req, res) => { }); }); +// PAM authentication endpoint +router.post('/pam-login', async (req, res) => { + try { + const { username, password } = req.body; + + // Validate input + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + // Check if PAM is available + const pamAvailable = await pamAuth.isAvailable(); + if (!pamAvailable) { + return res.status(501).json({ error: 'PAM authentication is not available on this system' }); + } + + // Authenticate using PAM + const isAuthenticated = await pamAuth.authenticate(username, password); + + if (!isAuthenticated) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + // Get user info from system + const userInfo = await pamAuth.getUserInfo(username); + + // Create or get user from database + let user = userDb.getUserByUsername(username); + + if (!user) { + // Create new user in database if doesn't exist + user = userDb.createUser(username, null, true); // true for PAM user + } else { + // Update user to be PAM authenticated + userDb.updateUserPamStatus(user.id, true); + } + + // Generate token + const token = generateToken(user); + + // Update last login + userDb.updateLastLogin(user.id); + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + userInfo: userInfo, + isPamUser: true + }, + token + }); + + } catch (error) { + console.error('PAM login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Check PAM availability +router.get('/pam-status', async (req, res) => { + try { + const pamAvailable = await pamAuth.isAvailable(); + res.json({ + pamAvailable, + message: pamAvailable ? 'PAM authentication is available' : 'PAM authentication is not available' + }); + } catch (error) { + console.error('PAM status check error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Logout (client-side token removal, but this endpoint can be used for logging) router.post('/logout', authenticateToken, (req, res) => { // In a simple JWT system, logout is mainly client-side diff --git a/server/services/pamAuth.js b/server/services/pamAuth.js new file mode 100644 index 00000000..b5774025 --- /dev/null +++ b/server/services/pamAuth.js @@ -0,0 +1,132 @@ +import { spawn } from 'child_process'; +import { promisify } from 'util'; + +class PAMAuthService { + constructor() { + this.serviceName = 'login'; // Default PAM service + } + + /** + * Authenticate a user using PAM via system commands + * @param {string} username - The username to authenticate + * @param {string} password - The password to verify + * @returns {Promise} True if authentication succeeds + */ + async authenticate(username, password) { + try { + // Only use su command for PAM authentication + const result = await this.authenticateWithSu(username, password); + return result; + } catch (error) { + console.error('PAM authentication error:', error); + return false; + } + } + + /** + * Authenticate using su command + */ + async authenticateWithSu(username, password) { + return new Promise((resolve) => { + const child = spawn('su', [username, '-c', 'exit'], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let output = ''; + let error = ''; + + child.stdout.on('data', (data) => { + output += data.toString(); + }); + + child.stderr.on('data', (data) => { + error += data.toString(); + }); + + child.on('close', (code) => { + resolve(code === 0); + }); + + child.on('error', () => { + resolve(false); + }); + + // Send password to stdin + child.stdin.write(password + '\n'); + child.stdin.end(); + + // Timeout after 5 seconds + setTimeout(() => { + if (!child.killed) { + child.kill(); + resolve(false); + } + }, 5000); + }); + } + + + + /** + * Check if PAM authentication is available on this system + */ + async isAvailable() { + try { + // Check if 'su' command is available (only command we need) + const child = spawn('which', ['su']); + await new Promise((resolve) => { + child.on('close', resolve); + }); + + return child.exitCode === 0; + } catch (error) { + return false; + } + } + + /** + * Get user information from system + */ + async getUserInfo(username) { + return new Promise((resolve) => { + const child = spawn('getent', ['passwd', username], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let output = ''; + + child.stdout.on('data', (data) => { + output += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0 && output) { + const parts = output.trim().split(':'); + resolve({ + username: parts[0], + uid: parts[2], + gid: parts[3], + name: parts[4], + home: parts[5], + shell: parts[6] + }); + } else { + resolve(null); + } + }); + + child.on('error', () => { + resolve(null); + }); + }); + } + + /** + * Set the PAM service name + */ + setServiceName(serviceName) { + this.serviceName = serviceName; + } +} + +export default new PAMAuthService(); \ No newline at end of file diff --git a/src/components/LoginForm.jsx b/src/components/LoginForm.jsx index f2a490a1..47e7b06f 100644 --- a/src/components/LoginForm.jsx +++ b/src/components/LoginForm.jsx @@ -1,32 +1,38 @@ import React, { useState } from 'react'; import { useAuth } from '../contexts/AuthContext'; -import { MessageSquare } from 'lucide-react'; +import { MessageSquare, User, Key } from 'lucide-react'; const LoginForm = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - - const { login } = useAuth(); + const [authMode, setAuthMode] = useState('local'); // 'local' or 'pam' + + const { login, pamLogin, pamAvailable } = useAuth(); const handleSubmit = async (e) => { e.preventDefault(); setError(''); - + if (!username || !password) { setError('Please enter both username and password'); return; } - + setIsLoading(true); - - const result = await login(username, password); - + + let result; + if (authMode === 'pam' && pamAvailable) { + result = await pamLogin(username, password); + } else { + result = await login(username, password); + } + if (!result.success) { setError(result.error); } - + setIsLoading(false); }; @@ -47,6 +53,36 @@ const LoginForm = () => {

+ {/* Authentication Mode Selector */} + {pamAvailable && ( +
+ + +
+ )} + {/* Login Form */}
@@ -92,14 +128,27 @@ const LoginForm = () => { disabled={isLoading} className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200" > - {isLoading ? 'Signing in...' : 'Sign In'} + {isLoading + ? 'Signing in...' + : authMode === 'pam' + ? 'Sign in with Linux PAM' + : 'Sign In' + }

- Enter your credentials to access Claude Code UI + {authMode === 'pam' + ? 'Enter your Linux system credentials' + : 'Enter your Claude Code UI account credentials' + }

+ {authMode === 'pam' && ( +

+ Uses system authentication via Linux PAM +

+ )}
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 77acb6c6..9e83262d 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -6,9 +6,11 @@ const AuthContext = createContext({ token: null, login: () => {}, register: () => {}, + pamLogin: () => {}, logout: () => {}, isLoading: true, needsSetup: false, + pamAvailable: false, error: null }); @@ -25,6 +27,7 @@ export const AuthProvider = ({ children }) => { const [token, setToken] = useState(localStorage.getItem('auth-token')); const [isLoading, setIsLoading] = useState(true); const [needsSetup, setNeedsSetup] = useState(false); + const [pamAvailable, setPamAvailable] = useState(false); const [error, setError] = useState(null); // Check authentication status on mount @@ -36,22 +39,24 @@ export const AuthProvider = ({ children }) => { try { setIsLoading(true); setError(null); - - // Check if system needs setup + + // Check if system needs setup and PAM availability const statusResponse = await api.auth.status(); const statusData = await statusResponse.json(); - + + setPamAvailable(statusData.pamAvailable || false); + if (statusData.needsSetup) { setNeedsSetup(true); setIsLoading(false); return; } - + // If we have a token, verify it if (token) { try { const userResponse = await api.auth.user(); - + if (userResponse.ok) { const userData = await userResponse.json(); setUser(userData.user); @@ -126,6 +131,31 @@ export const AuthProvider = ({ children }) => { } }; + const pamLogin = async (username, password) => { + try { + setError(null); + const response = await api.auth.pamLogin(username, password); + + const data = await response.json(); + + if (response.ok) { + setToken(data.token); + setUser(data.user); + setNeedsSetup(false); + localStorage.setItem('auth-token', data.token); + return { success: true }; + } else { + setError(data.error || 'PAM login failed'); + return { success: false, error: data.error || 'PAM login failed' }; + } + } catch (error) { + console.error('PAM login error:', error); + const errorMessage = 'Network error. Please try again.'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } + }; + const logout = () => { setToken(null); setUser(null); @@ -144,9 +174,11 @@ export const AuthProvider = ({ children }) => { token, login, register, + pamLogin, logout, isLoading, needsSetup, + pamAvailable, error }; diff --git a/src/utils/api.js b/src/utils/api.js index e3a93d3f..461fcf83 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -34,6 +34,12 @@ export const api = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }), + pamLogin: (username, password) => fetch('/api/auth/pam-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }), + pamStatus: () => fetch('/api/auth/pam-status'), user: () => authenticatedFetch('/api/auth/user'), logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }), },