From 9765095babb3a16c81df841731b5e279aa33a5ce Mon Sep 17 00:00:00 2001 From: arthur Date: Sun, 28 Sep 2025 22:47:53 +0700 Subject: [PATCH] certificate authentication, remove username/password --- README.md | 21 ++++---- access/auth.py | 49 +++++++------------ access/db.py | 19 +++++-- access/utils.py | 23 ++++----- connection.md => docs/connection.md | 0 .../integration-plan.md | 0 vpn-plan.md => docs/vpn-plan.md | 11 ++++- 7 files changed, 65 insertions(+), 58 deletions(-) rename connection.md => docs/connection.md (100%) rename integration-plan.md => docs/integration-plan.md (100%) rename vpn-plan.md => docs/vpn-plan.md (86%) diff --git a/README.md b/README.md index eeda40b..7af3e23 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,17 @@ The goal of this project is to extend an OpenVPN-based VPN access server with th ## 3. Database Schema ### `employees` table -| Column | Type | Notes | -|-----------------|--------------|----------------------------------------------------| -| id | INT (PK) | Primary key | -| username | VARCHAR(255) | Unique, not null, indexed | -| employee_name | VARCHAR(255) | Employee/VPN name (not null) | -| employee_email | VARCHAR(255) | Unique email, indexed | -| is_active | BOOLEAN | Default TRUE, indexed | -| session_limit | INT | Max allowed session (secs) | -| created_at | DATETIME | Default CURRENT_TIMESTAMP | -| updated_at | DATETIME | Auto-updated on change | +| Column | Type | Notes | +|----------------|--------------|----------------------------------------------------| +| id | INT (PK) | Primary key | +| username | VARCHAR(255) | Unique, not null, indexed | +| password | VARCHAR(255) | not null | +| employee_name | VARCHAR(255) | Employee/VPN name (not null) | +| employee_email | VARCHAR(255) | Unique email, indexed | +| is_active | BOOLEAN | Default TRUE, indexed | +| session_limit | INT | Max allowed session (secs) | +| created_at | DATETIME | Default CURRENT_TIMESTAMP | +| updated_at | DATETIME | Auto-updated on change | **Constraints**: `UNIQUE (username)`, `UNIQUE (employee_email)` **Indexes**: `idx_username`, `idx_email`, `idx_active` diff --git a/access/auth.py b/access/auth.py index 852f34a..daf302c 100755 --- a/access/auth.py +++ b/access/auth.py @@ -2,8 +2,8 @@ """ Authentication script for VPN Access Server. -This script is called by OpenVPN via the auth-user-pass-verify directive. -It validates user credentials and MAC addresses against the MySQL database. +This script is called by OpenVPN via the client-connect directive. +It validates user certificates (common_name) and MAC addresses against the MySQL database. Exit codes: - 0: Authentication successful @@ -22,32 +22,24 @@ from db import db from utils import setup_logging, get_env_vars, get_client_info, safe_exit -def authenticate_user(username: str, password: str) -> tuple[bool, dict]: +def authenticate_user(common_name: str) -> tuple[bool, dict]: """ - Authenticate user credentials against the database. + Authenticate user by certificate common name against the database. Args: - username: VPN username - password: VPN password + common_name: Certificate common name (username) Returns: Tuple of (success, user_info) """ try: - user = db.get_user_by_username(username) + user = db.get_user_by_username(common_name) if not user: return False, {} - # For now, we'll do simple password comparison - # In production, you should hash passwords and compare hashes - # This is a simplified implementation for demonstration - if user.get('password_hash'): # If password hashing is implemented - # You would implement proper password verification here - pass - else: - # Simple comparison for demonstration (NOT secure for production) - # In production, store and compare password hashes - pass + # Check if user is active + if not user.get('is_active', False): + return False, {} return True, user @@ -117,30 +109,25 @@ def main(): client_info = get_client_info(env_vars) # Log connection attempt - logger.info(f"Authentication attempt for user: {client_info['username']} " + logger.info(f"Authentication attempt for user: {client_info['common_name']} " f"from IP: {client_info['untrusted_ip']} " f"with MAC: {client_info['mac_address']}") # Validate client information if not client_info['is_valid']: missing_fields = [] - if not client_info['username']: - missing_fields.append('username') - if not client_info['password']: - missing_fields.append('password') + if not client_info['common_name']: + missing_fields.append('common_name') if not client_info['mac_address']: missing_fields.append('MAC address') safe_exit(1, f"Missing required fields: {', '.join(missing_fields)}", logger) - # Authenticate user - auth_success, user_info = authenticate_user( - client_info['username'], - client_info['password'] - ) + # Authenticate user by certificate common name + auth_success, user_info = authenticate_user(client_info['common_name']) if not auth_success: - safe_exit(1, f"Authentication failed for user: {client_info['username']}", logger) + safe_exit(1, f"Authentication failed for user: {client_info['common_name']}", logger) user_id = user_info['id'] session_limit = user_info.get('session_limit', config.server.default_session_limit) @@ -148,16 +135,16 @@ def main(): # Validate MAC address if not validate_mac_address(user_id, client_info['mac_address']): safe_exit(1, f"MAC address not authorized: {client_info['mac_address']} " - f"for user: {client_info['username']}", logger) + f"for user: {client_info['common_name']}", logger) # Check session limits if not check_session_limits(user_id, session_limit): daily_usage = db.get_user_daily_session_time(user_id) - safe_exit(1, f"Daily session limit exceeded for user: {client_info['username']} " + safe_exit(1, f"Daily session limit exceeded for user: {client_info['common_name']} " f"(used: {daily_usage}s, limit: {session_limit}s)", logger) # Authentication successful - logger.info(f"Authentication successful for user: {client_info['username']} " + logger.info(f"Authentication successful for user: {client_info['common_name']} " f"with MAC: {client_info['mac_address']}") safe_exit(0, "Authentication successful", logger) diff --git a/access/db.py b/access/db.py index 9f910e3..0de3b62 100644 --- a/access/db.py +++ b/access/db.py @@ -9,7 +9,7 @@ import logging import mysql.connector from mysql.connector import pooling, Error from typing import Optional, List, Dict, Any, Tuple -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from contextlib import contextmanager from config import config @@ -114,6 +114,15 @@ class DatabaseManager: """ return self.execute_query(query, (user_id,), 'one') + def get_user_password(self, username: str) -> Optional[Dict[str, Any]]: + """Get user details by username and password.""" + query = """ + SELECT PASSWORD FROM VPN.EMPLOYEES + WHERE VPN.EMPLOYEES.USERNAME = %s + AND VPN.EMPLOYEES.IS_ACTIVE = TRUE + """ + return self.execute_query(query, (username,), 'one') + # MAC address operations def get_user_mac_addresses(self, user_id: int) -> List[str]: """Get all MAC addresses for a user.""" @@ -141,7 +150,7 @@ class DatabaseManager: VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at) """ - now = datetime.utcnow() + now = datetime.now(timezone(timedelta(hours=7))) try: self.execute_query(query, (user_id, mac_address, now, now)) return True @@ -156,7 +165,7 @@ class DatabaseManager: INSERT INTO sessions (employee_id, start_time, created_at, updated_at) VALUES (%s, %s, %s, %s) """ - now = datetime.utcnow() + now = datetime.now(timezone(timedelta(hours=7))) try: self.execute_query(query, (user_id, now, now, now)) # Get the last inserted ID @@ -178,7 +187,7 @@ class DatabaseManager: updated_at = %s WHERE id = %s AND end_time IS NULL """ - now = datetime.utcnow() + now = datetime.now(timezone(timedelta(hours=7))) try: rows_affected = self.execute_query(query, (now, now, now, session_id)) return rows_affected > 0 @@ -189,7 +198,7 @@ class DatabaseManager: def get_user_daily_session_time(self, user_id: int, date: datetime = None) -> int: """Get total session time for a user on a specific date (in seconds).""" if date is None: - date = datetime.utcnow().date() + now = datetime.now(timezone(timedelta(hours=7))).date() start_of_day = datetime.combine(date, datetime.min.time()) end_of_day = start_of_day + timedelta(days=1) diff --git a/access/utils.py b/access/utils.py index 9a3f42d..246bf01 100644 --- a/access/utils.py +++ b/access/utils.py @@ -11,7 +11,7 @@ import sys import logging import hashlib from typing import Optional, Dict, Any, Tuple -from datetime import datetime +from datetime import datetime, timezone def setup_logging(log_level: str = 'INFO', log_file: Optional[str] = None) -> logging.Logger: @@ -107,10 +107,10 @@ def get_env_vars() -> Dict[str, str]: # Common OpenVPN environment variables openvpn_vars = [ - 'username', 'password', 'common_name', 'trusted_ip', 'trusted_port', + 'common_name', 'trusted_ip', 'trusted_port', 'untrusted_ip', 'untrusted_port', 'ifconfig_pool_remote_ip', 'script_type', 'time_ascii', 'time_unix', 'time_duration', - 'CLIENT_MAC' # Custom variable set by client + 'IV_HWADDR', 'CLIENT_MAC' # MAC address variables ] for var in openvpn_vars: @@ -220,24 +220,25 @@ def get_client_info(env_vars: Dict[str, str]) -> Dict[str, Any]: Dictionary with validated client information """ client_info = { - 'username': env_vars.get('username', '').strip(), - 'password': env_vars.get('password', '').strip(), 'common_name': env_vars.get('common_name', '').strip(), 'trusted_ip': env_vars.get('trusted_ip', '').strip(), 'untrusted_ip': env_vars.get('untrusted_ip', '').strip(), 'mac_address': None, - 'timestamp': datetime.utcnow() + 'timestamp': datetime.now(timezone.utc) } - # Process MAC address - raw_mac = env_vars.get('CLIENT_MAC', '').strip() + # Process MAC address from IV_HWADDR (requires push-peer-info in client config) + raw_mac = env_vars.get('IV_HWADDR', '').strip() + if not raw_mac: + # Fallback to CLIENT_MAC if IV_HWADDR not available + raw_mac = env_vars.get('CLIENT_MAC', '').strip() + if raw_mac: client_info['mac_address'] = normalize_mac_address(raw_mac) - # Validate required fields + # Validate required fields for certificate-based authentication client_info['is_valid'] = bool( - client_info['username'] and - client_info['password'] and + client_info['common_name'] and client_info['mac_address'] ) diff --git a/connection.md b/docs/connection.md similarity index 100% rename from connection.md rename to docs/connection.md diff --git a/integration-plan.md b/docs/integration-plan.md similarity index 100% rename from integration-plan.md rename to docs/integration-plan.md diff --git a/vpn-plan.md b/docs/vpn-plan.md similarity index 86% rename from vpn-plan.md rename to docs/vpn-plan.md index 3dd9c00..86d0138 100644 --- a/vpn-plan.md +++ b/docs/vpn-plan.md @@ -136,19 +136,28 @@ journalctl -u openvpn@server -f ## Step 8: Prepare Client Configuration +Information about the required file: +- ca.crt — The certificate authority file, used to verify the server certificate. +- [client_name].crt — The client certificate created (`/home/arthur/openvpn-ca/pki/issued/[client_name].crt`). +- [client_name].key — The client private key file, which should be located at `/home/arthur/openvpn-ca/pki/private/[client_name].key` +- ta.key — The TLS authentication key generated during server setup. + + Example client config (`client.ovpn`): ```conf client dev tun proto udp -remote 1194 +remote 113.22.221.198 1194 # use FTP IP address resolv-retry infinite nobind persist-key persist-tun remote-cert-tls server cipher AES-256-GCM +# push mac address info +push-peer-info verb 3