|
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)
|