Coverage for users/models/tokens.py: 71%

133 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1import hashlib 

2import hmac 

3import secrets 

4import zoneinfo 

5 

6from django.conf import settings 

7from django.contrib.postgres.fields import ArrayField 

8from django.core.exceptions import ValidationError 

9from django.core.validators import MinLengthValidator 

10from django.db import models 

11from django.db.models import Q 

12from django.urls import reverse 

13from django.utils import timezone 

14from django.utils.translation import gettext_lazy as _ 

15from netaddr import IPNetwork 

16 

17from ipam.fields import IPNetworkField 

18from users.choices import TokenVersionChoices 

19from users.constants import TOKEN_CHARSET, TOKEN_DEFAULT_LENGTH, TOKEN_KEY_LENGTH, TOKEN_PREFIX 

20from users.utils import get_current_pepper 

21from utilities.querysets import RestrictedQuerySet 

22 

23__all__ = ( 

24 'Token', 

25) 

26 

27 

28class Token(models.Model): 

29 """ 

30 An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. 

31 It also supports setting an expiration time and toggling write ability. 

32 """ 

33 _token = None 

34 

35 version = models.PositiveSmallIntegerField( 

36 verbose_name=_('version'), 

37 choices=TokenVersionChoices, 

38 default=TokenVersionChoices.V2, 

39 ) 

40 user = models.ForeignKey( 

41 to='users.User', 

42 on_delete=models.CASCADE, 

43 related_name='tokens' 

44 ) 

45 description = models.CharField( 

46 verbose_name=_('description'), 

47 max_length=200, 

48 blank=True 

49 ) 

50 created = models.DateTimeField( 

51 verbose_name=_('created'), 

52 auto_now_add=True 

53 ) 

54 expires = models.DateTimeField( 

55 verbose_name=_('expires'), 

56 blank=True, 

57 null=True 

58 ) 

59 last_used = models.DateTimeField( 

60 verbose_name=_('last used'), 

61 blank=True, 

62 null=True 

63 ) 

64 enabled = models.BooleanField( 

65 verbose_name=_('enabled'), 

66 default=True, 

67 help_text=_('Disable to temporarily revoke this token without deleting it.'), 

68 ) 

69 write_enabled = models.BooleanField( 

70 verbose_name=_('write enabled'), 

71 default=True, 

72 help_text=_('Permit create/update/delete operations using this token') 

73 ) 

74 # For legacy v1 tokens, this field stores the plaintext 40-char token value. Not used for v2. 

75 plaintext = models.CharField( 

76 verbose_name=_('plaintext'), 

77 max_length=40, 

78 unique=True, 

79 blank=True, 

80 null=True, 

81 validators=[MinLengthValidator(40)], 

82 ) 

83 key = models.CharField( 

84 verbose_name=_('key'), 

85 max_length=TOKEN_KEY_LENGTH, 

86 unique=True, 

87 blank=True, 

88 null=True, 

89 validators=[MinLengthValidator(TOKEN_KEY_LENGTH)], 

90 help_text=_('v2 token identification key'), 

91 ) 

92 pepper_id = models.PositiveSmallIntegerField( 

93 verbose_name=_('pepper ID'), 

94 blank=True, 

95 null=True, 

96 help_text=_('ID of the cryptographic pepper used to hash the token (v2 only)'), 

97 ) 

98 hmac_digest = models.CharField( 

99 verbose_name=_('digest'), 

100 max_length=64, 

101 blank=True, 

102 null=True, 

103 help_text=_('SHA256 hash of the token and pepper (v2 only)'), 

104 ) 

105 allowed_ips = ArrayField( 

106 base_field=IPNetworkField(), 

107 blank=True, 

108 null=True, 

109 verbose_name=_('allowed IPs'), 

110 help_text=_( 

111 'Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' 

112 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"' 

113 ), 

114 ) 

115 

116 objects = RestrictedQuerySet.as_manager() 

117 

118 class Meta: 

119 ordering = ('-created',) 

120 indexes = ( 

121 models.Index(fields=('-created',)), # Default ordering 

122 ) 

123 verbose_name = _('token') 

124 verbose_name_plural = _('tokens') 

125 constraints = [ 

126 models.CheckConstraint( 

127 name='enforce_version_dependent_fields', 

128 condition=( 

129 Q( 

130 version=1, 

131 key__isnull=True, 

132 pepper_id__isnull=True, 

133 hmac_digest__isnull=True, 

134 plaintext__isnull=False 

135 ) | 

136 Q( 

137 version=2, 

138 key__isnull=False, 

139 pepper_id__isnull=False, 

140 hmac_digest__isnull=False, 

141 plaintext__isnull=True 

142 ) 

143 ), 

144 ), 

145 ] 

146 

147 def __init__(self, *args, token=None, **kwargs): 

148 super().__init__(*args, **kwargs) 

149 

150 # This stores the initial plaintext value (if given) on the creation of a new Token. If not provided, a 

151 # random token value will be generated and assigned immediately prior to saving the Token instance. 

152 self.token = token 

153 

154 def __str__(self): 

155 return self.key if self.v2 else self.partial 

156 

157 def get_absolute_url(self): 

158 return reverse('users:token', args=[self.pk]) 

159 

160 @property 

161 def v1(self): 

162 return self.version == 1 

163 

164 @property 

165 def v2(self): 

166 return self.version == 2 

167 

168 @property 

169 def partial(self): 

170 """ 

171 Return a sanitized representation of a v1 token. 

172 """ 

173 return f'**********************************{self.plaintext[-6:]}' if self.plaintext else '' 

174 

175 @property 

176 def token(self): 

177 return self._token 

178 

179 @token.setter 

180 def token(self, value): 

181 if not self._state.adding: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true

182 raise ValueError("Cannot assign a new plaintext value for an existing token.") 

183 self._token = value 

184 if value is not None: 

185 if self.v1: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 self.plaintext = value 

187 elif self.v2: 187 ↛ exitline 187 didn't return from function 'token' because the condition on line 187 was always true

188 self.key = self.key or self.generate_key() 

189 self.update_digest() 

190 

191 @property 

192 def is_expired(self): 

193 """ 

194 Check whether the token has expired. 

195 """ 

196 if self.expires is None or timezone.now() < self.expires: 196 ↛ 198line 196 didn't jump to line 198 because the condition on line 196 was always true

197 return False 

198 return True 

199 

200 @property 

201 def is_active(self): 

202 """ 

203 Check whether the token is active (enabled and not expired). 

204 """ 

205 return self.enabled and not self.is_expired 

206 

207 def get_auth_header_prefix(self): 

208 """ 

209 Return the HTTP Authorization header prefix for this token. 

210 """ 

211 if self.v1: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 return 'Token ' 

213 if self.v2: 213 ↛ 215line 213 didn't jump to line 215 because the condition on line 213 was always true

214 return f'Bearer {TOKEN_PREFIX}{self.key}.' 

215 return None 

216 

217 def clean(self): 

218 super().clean() 

219 

220 if self.version == TokenVersionChoices.V2 and not settings.API_TOKEN_PEPPERS: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 raise ValidationError(_("Unable to save v2 tokens: API_TOKEN_PEPPERS is not defined.")) 

222 

223 if self._state.adding: 223 ↛ 228line 223 didn't jump to line 228 because the condition on line 223 was never true

224 # Ensure a randomly-generated plaintext is always assigned to new tokens. A client-supplied value is 

225 # never accepted via the REST API (the serializer's `token` field is read-only); generating it here 

226 # guarantees the version-dependent key/digest fields are populated before constraint validation 

227 # (full_clean) runs. 

228 if self.token is None: 

229 self.token = self.generate() 

230 

231 if self.pepper_id is not None and self.pepper_id not in settings.API_TOKEN_PEPPERS: 

232 raise ValidationError(_( 

233 "Invalid pepper ID: {id}. Check configured API_TOKEN_PEPPERS." 

234 ).format(id=self.pepper_id)) 

235 

236 # Prevent creating a token with a past expiration date 

237 # while allowing updates to existing tokens. 

238 if self.pk is None and self.is_expired: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE) 

240 now = timezone.now().astimezone(current_tz) 

241 current_time_str = f'{now.date().isoformat()} {now.time().isoformat(timespec="seconds")}' 

242 

243 # Translators: {current_time} is the current server date and time in ISO format, 

244 # {timezone} is the configured server time zone (for example, "UTC" or "Europe/Berlin"). 

245 message = _( 

246 'Expiration time must be in the future. Current server time is {current_time} ({timezone}).' 

247 ).format(current_time=current_time_str, timezone=current_tz.key) 

248 

249 raise ValidationError({'expires': message}) 

250 

251 def save(self, *args, **kwargs): 

252 # If creating a new Token and no token value has been specified, generate one 

253 if self._state.adding and self.token is None: 

254 self.token = self.generate() 

255 

256 return super().save(*args, **kwargs) 

257 

258 @classmethod 

259 def generate_key(cls): 

260 """ 

261 Generate and return a random alphanumeric key for v2 tokens. 

262 """ 

263 return cls.generate(length=TOKEN_KEY_LENGTH) 

264 

265 @staticmethod 

266 def generate(length=TOKEN_DEFAULT_LENGTH): 

267 """ 

268 Generate and return a random token value of the given length. 

269 """ 

270 return ''.join(secrets.choice(TOKEN_CHARSET) for _ in range(length)) 

271 

272 def update_digest(self): 

273 """ 

274 Recalculate and save the HMAC digest using the currently defined pepper and token values. 

275 """ 

276 self.pepper_id, pepper = get_current_pepper() 

277 self.hmac_digest = hmac.new( 

278 pepper.encode('utf-8'), 

279 self.token.encode('utf-8'), 

280 hashlib.sha256 

281 ).hexdigest() 

282 

283 def validate(self, token): 

284 """ 

285 Validate the given plaintext against the token. 

286 

287 For v1 tokens, check that the given value is equal to the stored plaintext. For v2 tokens, calculate an HMAC 

288 from the Token's pepper ID and the given plaintext value, and check whether the result matches the recorded 

289 digest. 

290 """ 

291 if self.v1: 291 ↛ 292line 291 didn't jump to line 292 because the condition on line 291 was never true

292 return hmac.compare_digest(token, self.plaintext) 

293 if self.v2: 293 ↛ 302line 293 didn't jump to line 302 because the condition on line 293 was always true

294 token = token.removeprefix(TOKEN_PREFIX) 

295 try: 

296 pepper = settings.API_TOKEN_PEPPERS[self.pepper_id] 

297 except KeyError: 

298 # Invalid pepper ID 

299 return False 

300 digest = hmac.new(pepper.encode('utf-8'), token.encode('utf-8'), hashlib.sha256).hexdigest() 

301 return hmac.compare_digest(digest, self.hmac_digest) 

302 return False 

303 

304 def validate_client_ip(self, client_ip): 

305 """ 

306 Validate the API client IP address against the source IP restrictions (if any) set on the token. 

307 """ 

308 if not self.allowed_ips: 

309 return True 

310 

311 for ip_network in self.allowed_ips: 

312 if client_ip in IPNetwork(ip_network): 

313 return True 

314 

315 return False