Project

General

Profile

Feature #16582 » googleauth-configurable.py

Loic FONTAINE, 12/13/2025 07:49 AM

 
1
#!/usr/local/bin/python3
2
#
3
# Google Authenticator verification script for FreeRADIUS
4
# Part of pfSense (https://www.pfsense.org)
5
#
6
# SPDX-License-Identifier: Apache-2.0
7
#
8
# This script validates TOTP codes for FreeRADIUS authentication
9
# Enhanced with configurable anti-replay protection (RFC 6238 compliance)
10
#
11
# Usage: googleauth.py <username> <secret> <pin> <password> [antireplay]
12
#   antireplay: '1' to enable, '0' or omitted to disable
13
#
14

    
15
import sys
16
import time
17
import struct
18
import hmac
19
import hashlib
20
import base64
21
import syslog
22
import os
23
import fcntl
24

    
25
# Anti-replay configuration
26
USED_TOKENS_DIR = "/var/run/freeradius"
27
USED_TOKENS_FILE = os.path.join(USED_TOKENS_DIR, "totp_used_tokens")
28
TOKEN_EXPIRY_SECONDS = 90  # Tokens expire after 90 seconds (3 TOTP windows)
29

    
30

    
31
def ensure_token_dir():
32
    """Ensure the token directory exists with proper permissions."""
33
    if not os.path.exists(USED_TOKENS_DIR):
34
        try:
35
            os.makedirs(USED_TOKENS_DIR, mode=0o700)
36
        except OSError:
37
            pass
38

    
39

    
40
def is_token_used(username, token_code):
41
    """
42
    Check if the token has already been used (anti-replay protection).
43
    
44
    Args:
45
        username: The username to check
46
        token_code: The TOTP code to verify
47
        
48
    Returns:
49
        True if token was already used, False otherwise
50
    """
51
    ensure_token_dir()
52
    try:
53
        if not os.path.exists(USED_TOKENS_FILE):
54
            return False
55
        current_time = time.time()
56
        with open(USED_TOKENS_FILE, 'r') as f:
57
            for line in f:
58
                parts = line.strip().split(':')
59
                if len(parts) == 3:
60
                    stored_user, stored_token, stored_time = parts
61
                    if stored_user == username and stored_token == token_code:
62
                        if current_time - float(stored_time) < TOKEN_EXPIRY_SECONDS:
63
                            return True
64
        return False
65
    except (IOError, ValueError):
66
        return False
67

    
68

    
69
def mark_token_used(username, token_code):
70
    """
71
    Record the token as used to prevent replay attacks.
72
    
73
    Args:
74
        username: The username that used the token
75
        token_code: The TOTP code that was used
76
    """
77
    ensure_token_dir()
78
    try:
79
        with open(USED_TOKENS_FILE, 'a') as f:
80
            fcntl.flock(f, fcntl.LOCK_EX)
81
            f.write("{}:{}:{}\n".format(username, token_code, time.time()))
82
            fcntl.flock(f, fcntl.LOCK_UN)
83
        cleanup_expired_tokens()
84
    except IOError:
85
        pass
86

    
87

    
88
def cleanup_expired_tokens():
89
    """Remove expired token entries to prevent file growth."""
90
    try:
91
        if not os.path.exists(USED_TOKENS_FILE):
92
            return
93
        current_time = time.time()
94
        valid_lines = []
95
        with open(USED_TOKENS_FILE, 'r') as f:
96
            for line in f:
97
                parts = line.strip().split(':')
98
                if len(parts) == 3:
99
                    try:
100
                        if current_time - float(parts[2]) < TOKEN_EXPIRY_SECONDS:
101
                            valid_lines.append(line)
102
                    except ValueError:
103
                        pass
104
        with open(USED_TOKENS_FILE, 'w') as f:
105
            fcntl.flock(f, fcntl.LOCK_EX)
106
            f.writelines(valid_lines)
107
            fcntl.flock(f, fcntl.LOCK_UN)
108
    except IOError:
109
        pass
110

    
111

    
112
def authenticate(username, secretkey, pin, password, antireplay_enabled=False):
113
    """
114
    Authenticate user with PIN + TOTP code.
115
    
116
    Args:
117
        username: The username attempting authentication
118
        secretkey: Base32-encoded TOTP secret
119
        pin: User's static PIN
120
        password: Full password (PIN + TOTP code concatenated)
121
        antireplay_enabled: Whether to check for replay attacks
122
    
123
    Returns:
124
        True if authentication successful, False otherwise
125
    """
126
    # Validate password length
127
    if len(password) < len(pin):
128
        syslog.syslog(syslog.LOG_ERR,
129
            "freeRADIUS: Google Authenticator - Authentication failed. "
130
            "User: " + username + ", Reason: wrong PIN")
131
        return False
132

    
133
    # Verify PIN prefix
134
    if pin != password[:len(pin)]:
135
        syslog.syslog(syslog.LOG_ERR,
136
            "freeRADIUS: Google Authenticator - Authentication failed. "
137
            "User: " + username + ", Reason: wrong PIN")
138
        return False
139

    
140
    # Extract the OTP code
141
    otp_attempt = password[len(pin):]
142
    
143
    # Anti-replay check (only if enabled)
144
    if antireplay_enabled:
145
        if is_token_used(username, otp_attempt):
146
            syslog.syslog(syslog.LOG_WARNING,
147
                "freeRADIUS: Google Authenticator - REPLAY ATTACK DETECTED. "
148
                "User: " + username + ", Token already used")
149
            return False
150

    
151
    # Decode the secret key
152
    try:
153
        key = base64.b32decode(secretkey)
154
    except Exception:
155
        syslog.syslog(syslog.LOG_ERR,
156
            "freeRADIUS: Google Authenticator - Authentication failed. "
157
            "User: " + username + ", Reason: invalid secret key")
158
        return False
159

    
160
    # Get current time window
161
    tm = int(time.time() / 30)
162

    
163
    # Check current time window and adjacent windows for clock drift tolerance
164
    for offset in [-1, 0, 1]:
165
        try:
166
            b = struct.pack(">q", tm + offset)
167
            hm = hmac.HMAC(key, b, hashlib.sha1).digest()
168
            extract_offset = hm[-1] & 0x0F
169
            truncated = hm[extract_offset:extract_offset + 4]
170
            code = struct.unpack(">L", truncated)[0]
171
            code &= 0x7FFFFFFF
172
            code %= 1000000
173

    
174
            if ("%06d" % code) == str(otp_attempt):
175
                # Mark token as used (only if anti-replay enabled)
176
                if antireplay_enabled:
177
                    mark_token_used(username, otp_attempt)
178
                syslog.syslog(syslog.LOG_NOTICE,
179
                    "freeRADIUS: Google Authenticator - "
180
                    "Authentication successful for user: " + username)
181
                return True
182
        except Exception:
183
            continue
184

    
185
    syslog.syslog(syslog.LOG_ERR,
186
        "freeRADIUS: Google Authenticator - Authentication failed. "
187
        "User: " + username + ", Reason: wrong OTP code")
188
    return False
189

    
190

    
191
if __name__ == "__main__":
192
    # Validate command line arguments
193
    # Usage: googleauth.py <username> <secret> <pin> <password> [antireplay]
194
    if len(sys.argv) < 5:
195
        syslog.syslog(syslog.LOG_ERR,
196
            "freeRADIUS: Google Authenticator - Invalid number of parameters. "
197
            "Expected: username secretkey pin password [antireplay]")
198
        sys.exit(1)
199

    
200
    username = sys.argv[1]
201
    secretkey = sys.argv[2]
202
    pin = sys.argv[3]
203
    password = sys.argv[4]
204
    
205
    # Check for anti-replay parameter (5th argument)
206
    # '1' = enabled, '0' or missing = disabled
207
    antireplay_enabled = False
208
    if len(sys.argv) >= 6:
209
        antireplay_enabled = (sys.argv[5] == '1')
210

    
211
    if authenticate(username, secretkey, pin, password, antireplay_enabled):
212
        sys.exit(0)
213
    else:
214
        sys.exit(1)
(1-1/5)