the37lab-authlib 0.1.1756740066__py3-none-any.whl → 0.1.1758263039__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of the37lab-authlib might be problematic. Click here for more details.
- the37lab_authlib/__init__.py +4 -4
- the37lab_authlib/auth.py +1148 -854
- the37lab_authlib/db.py +90 -90
- the37lab_authlib/decorators.py +32 -32
- the37lab_authlib/exceptions.py +10 -10
- the37lab_authlib/models.py +94 -94
- {the37lab_authlib-0.1.1756740066.dist-info → the37lab_authlib-0.1.1758263039.dist-info}/METADATA +1 -1
- the37lab_authlib-0.1.1758263039.dist-info/RECORD +10 -0
- the37lab_authlib-0.1.1756740066.dist-info/RECORD +0 -10
- {the37lab_authlib-0.1.1756740066.dist-info → the37lab_authlib-0.1.1758263039.dist-info}/WHEEL +0 -0
- {the37lab_authlib-0.1.1756740066.dist-info → the37lab_authlib-0.1.1758263039.dist-info}/top_level.txt +0 -0
the37lab_authlib/auth.py
CHANGED
|
@@ -1,854 +1,1148 @@
|
|
|
1
|
-
import inspect
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
from
|
|
6
|
-
from .
|
|
7
|
-
from .
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
from
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
self.
|
|
26
|
-
self.
|
|
27
|
-
self.
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
self.
|
|
70
|
-
self.
|
|
71
|
-
self.
|
|
72
|
-
self.
|
|
73
|
-
|
|
74
|
-
'auth.
|
|
75
|
-
'auth.
|
|
76
|
-
'auth.
|
|
77
|
-
'auth.
|
|
78
|
-
'auth.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
'
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
LEFT JOIN
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
'
|
|
164
|
-
'
|
|
165
|
-
'
|
|
166
|
-
'
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
'
|
|
184
|
-
'
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
self.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
app.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
response
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
bp
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
response
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
'
|
|
287
|
-
'
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
logger.error(f"
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
#
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
'
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
'
|
|
357
|
-
'
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
'
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
token =
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
"""
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
token =
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
"""
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
if not
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
self.
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
WHERE
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1
|
+
import inspect
|
|
2
|
+
import inspect
|
|
3
|
+
from flask import Blueprint, request, jsonify, current_app, url_for, redirect, g
|
|
4
|
+
import jwt
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from .db import Database
|
|
7
|
+
from .models import User, Role, ApiToken
|
|
8
|
+
from .exceptions import AuthError
|
|
9
|
+
import uuid
|
|
10
|
+
import requests
|
|
11
|
+
import bcrypt
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from functools import wraps
|
|
15
|
+
from isodate import parse_duration
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import msal
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
class AuthManager:
|
|
24
|
+
def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer', environment_prefix=None, api_tokens=None, cache_ttl=10, allow_oauth_auto_create=False):
|
|
25
|
+
self.user_override = None
|
|
26
|
+
self._user_cache = {}
|
|
27
|
+
self._cache_ttl = cache_ttl or 10 # 10 seconds
|
|
28
|
+
self._last_used_updates = {} # Track pending updates
|
|
29
|
+
self._update_lock = threading.Lock()
|
|
30
|
+
self._update_thread = None
|
|
31
|
+
self._shutdown_event = threading.Event()
|
|
32
|
+
# OAuth user creation policy (can be controlled by env)
|
|
33
|
+
self.allow_oauth_auto_create = allow_oauth_auto_create
|
|
34
|
+
|
|
35
|
+
if environment_prefix:
|
|
36
|
+
prefix = environment_prefix.upper() + '_'
|
|
37
|
+
db_dsn = os.getenv(f'{prefix}DATABASE_URL')
|
|
38
|
+
jwt_secret = os.getenv(f'{prefix}JWT_SECRET')
|
|
39
|
+
google_client_id = os.getenv(f'{prefix}GOOGLE_CLIENT_ID')
|
|
40
|
+
google_client_secret = os.getenv(f'{prefix}GOOGLE_CLIENT_SECRET')
|
|
41
|
+
oauth_config = {}
|
|
42
|
+
if google_client_id and google_client_secret:
|
|
43
|
+
oauth_config['google'] = {
|
|
44
|
+
'client_id': google_client_id,
|
|
45
|
+
'client_secret': google_client_secret
|
|
46
|
+
}
|
|
47
|
+
# Allow control via prefixed env var (defaults to True)
|
|
48
|
+
auto_create_env = os.getenv(f'{prefix}OAUTH_ALLOW_AUTO_CREATE')
|
|
49
|
+
if auto_create_env is not None:
|
|
50
|
+
self.allow_oauth_auto_create = auto_create_env.lower() in ['1', 'true', 'yes']
|
|
51
|
+
api_tokens_env = os.getenv(f'{prefix}API_TOKENS')
|
|
52
|
+
if api_tokens_env:
|
|
53
|
+
api_tokens = {}
|
|
54
|
+
for entry in api_tokens_env.split(','):
|
|
55
|
+
if ':' in entry:
|
|
56
|
+
key, user = entry.split(':', 1)
|
|
57
|
+
api_tokens[key.strip()] = user.strip()
|
|
58
|
+
user_override_env = os.getenv(f'{prefix}USER_OVERRIDE')
|
|
59
|
+
if user_override_env:
|
|
60
|
+
self.user_override = user_override_env
|
|
61
|
+
else:
|
|
62
|
+
prefix = ''
|
|
63
|
+
|
|
64
|
+
self.expiry_time = parse_duration(os.getenv(f'{prefix}JWT_TOKEN_EXPIRY_TIME', 'PT1H'))
|
|
65
|
+
if self.user_override and (api_tokens or db_dsn):
|
|
66
|
+
raise ValueError('Cannot set user_override together with api_tokens or db_dsn')
|
|
67
|
+
if api_tokens and db_dsn:
|
|
68
|
+
raise ValueError('Cannot set both api_tokens and db_dsn')
|
|
69
|
+
self.api_tokens = api_tokens or None
|
|
70
|
+
self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
|
|
71
|
+
self.jwt_secret = jwt_secret
|
|
72
|
+
self.oauth_config = oauth_config or {}
|
|
73
|
+
self.public_endpoints = {
|
|
74
|
+
'auth.login',
|
|
75
|
+
'auth.oauth_login',
|
|
76
|
+
'auth.oauth_callback',
|
|
77
|
+
'auth.refresh_token',
|
|
78
|
+
'auth.register',
|
|
79
|
+
'auth.get_roles'
|
|
80
|
+
}
|
|
81
|
+
self.bp = None
|
|
82
|
+
|
|
83
|
+
if app:
|
|
84
|
+
self.init_app(app)
|
|
85
|
+
|
|
86
|
+
# Start the background update thread
|
|
87
|
+
self._start_update_thread()
|
|
88
|
+
|
|
89
|
+
def _extract_token_from_header(self):
|
|
90
|
+
auth = request.authorization
|
|
91
|
+
if not auth or not auth.token:
|
|
92
|
+
raise AuthError('No authorization header or token', 401)
|
|
93
|
+
|
|
94
|
+
if auth.type.lower() != 'bearer':
|
|
95
|
+
raise AuthError('Invalid authorization scheme', 401)
|
|
96
|
+
|
|
97
|
+
return auth.token
|
|
98
|
+
|
|
99
|
+
def get_redirect_uri(self):
|
|
100
|
+
redirect_uri = os.getenv('REDIRECT_URL') or url_for('auth.oauth_callback', _external=True).replace("http://", "https://")
|
|
101
|
+
logger.info(f"REDIRECT URI..: {redirect_uri}")
|
|
102
|
+
return redirect_uri
|
|
103
|
+
|
|
104
|
+
def _validate_api_token(self, api_token):
|
|
105
|
+
if self.api_tokens is not None:
|
|
106
|
+
username = self.api_tokens.get(api_token)
|
|
107
|
+
if not username:
|
|
108
|
+
raise AuthError('Invalid API token')
|
|
109
|
+
# Return a minimal user dict
|
|
110
|
+
return {
|
|
111
|
+
'id': username,
|
|
112
|
+
'username': username,
|
|
113
|
+
'email': '',
|
|
114
|
+
'real_name': username,
|
|
115
|
+
'roles': []
|
|
116
|
+
}
|
|
117
|
+
try:
|
|
118
|
+
parsed = ApiToken.parse_token(api_token)
|
|
119
|
+
|
|
120
|
+
# Check cache first
|
|
121
|
+
cache_key = f"api_token_{parsed['id']}"
|
|
122
|
+
current_time = datetime.utcnow()
|
|
123
|
+
|
|
124
|
+
if cache_key in self._user_cache:
|
|
125
|
+
cached_data, cache_time = self._user_cache[cache_key]
|
|
126
|
+
if (current_time - cache_time).total_seconds() < self._cache_ttl:
|
|
127
|
+
logger.debug(f"Returning cached API token data for ID: {parsed['id']}")
|
|
128
|
+
return cached_data.copy() # Return a copy to avoid modifying cache
|
|
129
|
+
|
|
130
|
+
# Cache miss or expired, fetch from database
|
|
131
|
+
with self.db.get_cursor() as cur:
|
|
132
|
+
# First get the API token record
|
|
133
|
+
cur.execute("""
|
|
134
|
+
SELECT t.*, u.*, r.name as role_name FROM api_tokens t
|
|
135
|
+
JOIN users u ON t.user_id = u.id
|
|
136
|
+
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
137
|
+
LEFT JOIN roles r ON ur.role_id = r.id
|
|
138
|
+
WHERE t.id = %s
|
|
139
|
+
""", (parsed['id'],))
|
|
140
|
+
results = cur.fetchall()
|
|
141
|
+
if not results:
|
|
142
|
+
raise AuthError('Invalid API token')
|
|
143
|
+
|
|
144
|
+
# Get the first row for token/user data (all rows will have same token/user data)
|
|
145
|
+
result = results[0]
|
|
146
|
+
|
|
147
|
+
# Verify the nonce
|
|
148
|
+
if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
|
|
149
|
+
raise AuthError('Invalid API token')
|
|
150
|
+
|
|
151
|
+
# Check if token is expired
|
|
152
|
+
if result['expires_at'] and result['expires_at'] < datetime.utcnow():
|
|
153
|
+
raise AuthError('API token has expired')
|
|
154
|
+
|
|
155
|
+
# Schedule last used timestamp update (asynchronous with 10s delay)
|
|
156
|
+
self._schedule_last_used_update(parsed['id'])
|
|
157
|
+
|
|
158
|
+
# Extract roles from results
|
|
159
|
+
roles = [row['role_name'] for row in results if row['role_name'] is not None]
|
|
160
|
+
|
|
161
|
+
# Construct user object
|
|
162
|
+
user_data = {
|
|
163
|
+
'id': result['user_id'],
|
|
164
|
+
'username': result['username'],
|
|
165
|
+
'email': result['email'],
|
|
166
|
+
'real_name': result['real_name'],
|
|
167
|
+
'roles': roles
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Cache the result
|
|
171
|
+
self._user_cache[cache_key] = (user_data.copy(), current_time)
|
|
172
|
+
|
|
173
|
+
# Clean up expired cache entries
|
|
174
|
+
self._cleanup_cache()
|
|
175
|
+
|
|
176
|
+
return user_data
|
|
177
|
+
except ValueError:
|
|
178
|
+
raise AuthError('Invalid token format')
|
|
179
|
+
|
|
180
|
+
def _authenticate_request(self):
|
|
181
|
+
if self.user_override:
|
|
182
|
+
return {
|
|
183
|
+
'id': self.user_override,
|
|
184
|
+
'username': self.user_override,
|
|
185
|
+
'email': '',
|
|
186
|
+
'real_name': self.user_override,
|
|
187
|
+
'roles': []
|
|
188
|
+
}
|
|
189
|
+
auth_header = request.headers.get('Authorization')
|
|
190
|
+
api_token = request.headers.get('X-API-Token')
|
|
191
|
+
|
|
192
|
+
if auth_header and auth_header.startswith('Bearer '):
|
|
193
|
+
# JWT authentication
|
|
194
|
+
token = self._extract_token_from_header()
|
|
195
|
+
return self.validate_token(token)
|
|
196
|
+
elif api_token:
|
|
197
|
+
# API token authentication
|
|
198
|
+
return self._validate_api_token(api_token)
|
|
199
|
+
else:
|
|
200
|
+
raise AuthError('No authentication provided', 401)
|
|
201
|
+
|
|
202
|
+
def require_auth(self, f):
|
|
203
|
+
@wraps(f)
|
|
204
|
+
def decorated(*args, **kwargs):
|
|
205
|
+
user = self._authenticate_request()
|
|
206
|
+
sig = inspect.signature(f)
|
|
207
|
+
if 'requesting_user' in sig.parameters:
|
|
208
|
+
kwargs['requesting_user'] = user
|
|
209
|
+
|
|
210
|
+
return f(*args, **kwargs)
|
|
211
|
+
return decorated
|
|
212
|
+
|
|
213
|
+
def add_public_endpoint(self, endpoint):
|
|
214
|
+
"""Mark an endpoint as public so it bypasses authentication."""
|
|
215
|
+
self.public_endpoints.add(endpoint)
|
|
216
|
+
|
|
217
|
+
def public_endpoint(self, f):
|
|
218
|
+
"""Decorator to mark a view function as public."""
|
|
219
|
+
# Always register the bare function name so application level routes
|
|
220
|
+
# are exempt from authentication checks.
|
|
221
|
+
self.add_public_endpoint(f.__name__)
|
|
222
|
+
|
|
223
|
+
# If a blueprint is active, also register the blueprint-prefixed name
|
|
224
|
+
# used by Flask for endpoint identification.
|
|
225
|
+
if self.bp:
|
|
226
|
+
endpoint = f"{self.bp.name}.{f.__name__}"
|
|
227
|
+
self.add_public_endpoint(endpoint)
|
|
228
|
+
return f
|
|
229
|
+
|
|
230
|
+
def init_app(self, app):
|
|
231
|
+
app.auth_manager = self
|
|
232
|
+
app.register_blueprint(self.create_blueprint())
|
|
233
|
+
@app.errorhandler(AuthError)
|
|
234
|
+
def handle_auth_error(e):
|
|
235
|
+
response = jsonify(e.to_dict())
|
|
236
|
+
response.status_code = e.status_code
|
|
237
|
+
return response
|
|
238
|
+
|
|
239
|
+
def create_blueprint(self):
|
|
240
|
+
bp = Blueprint('auth', __name__, url_prefix='/api/v1/users')
|
|
241
|
+
self.bp = bp
|
|
242
|
+
bp.public_endpoint = self.public_endpoint
|
|
243
|
+
|
|
244
|
+
@bp.errorhandler(AuthError)
|
|
245
|
+
def handle_auth_error(err):
|
|
246
|
+
response = jsonify(err.to_dict())
|
|
247
|
+
response.status_code = err.status_code
|
|
248
|
+
return response
|
|
249
|
+
|
|
250
|
+
@bp.before_request
|
|
251
|
+
def load_user():
|
|
252
|
+
if request.method == 'OPTIONS':
|
|
253
|
+
return # Skip authentication for OPTIONS
|
|
254
|
+
if request.endpoint not in self.public_endpoints:
|
|
255
|
+
g.requesting_user = self._authenticate_request()
|
|
256
|
+
|
|
257
|
+
@bp.route('/login', methods=['POST'])
|
|
258
|
+
def login():
|
|
259
|
+
data = request.get_json()
|
|
260
|
+
username = data.get('username')
|
|
261
|
+
password = data.get('password')
|
|
262
|
+
|
|
263
|
+
if not username or not password:
|
|
264
|
+
raise AuthError('Username and password required', 400)
|
|
265
|
+
|
|
266
|
+
with self.db.get_cursor() as cur:
|
|
267
|
+
cur.execute("SELECT * FROM users WHERE username = %s", (username,))
|
|
268
|
+
user = cur.fetchone()
|
|
269
|
+
|
|
270
|
+
if not user or not self._verify_password(password, user['password_hash']):
|
|
271
|
+
raise AuthError('Invalid username or password', 401)
|
|
272
|
+
|
|
273
|
+
# Fetch roles
|
|
274
|
+
cur.execute("""
|
|
275
|
+
SELECT r.name FROM roles r
|
|
276
|
+
JOIN user_roles ur ON ur.role_id = r.id
|
|
277
|
+
WHERE ur.user_id = %s
|
|
278
|
+
""", (user['id'],))
|
|
279
|
+
roles = [row['name'] for row in cur.fetchall()]
|
|
280
|
+
user['roles'] = roles
|
|
281
|
+
|
|
282
|
+
token = self._create_token(user)
|
|
283
|
+
refresh_token = self._create_refresh_token(user)
|
|
284
|
+
|
|
285
|
+
return jsonify({
|
|
286
|
+
'token': token,
|
|
287
|
+
'refresh_token': refresh_token,
|
|
288
|
+
'user': user
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
@bp.route('/login/oauth', methods=['POST'])
|
|
292
|
+
def oauth_login():
|
|
293
|
+
provider = request.json.get('provider')
|
|
294
|
+
if provider not in self.oauth_config:
|
|
295
|
+
logger.error(f"Invalid OAuth provider: {provider}")
|
|
296
|
+
logger.error(f"These are the known ones: {self.oauth_config.keys()}")
|
|
297
|
+
raise AuthError('Invalid OAuth provider', 400)
|
|
298
|
+
|
|
299
|
+
redirect_uri = self.get_redirect_uri()
|
|
300
|
+
return jsonify({
|
|
301
|
+
'redirect_url': self._get_oauth_url(provider, redirect_uri)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
@bp.route('/login/oauth2callback')
|
|
305
|
+
def oauth_callback():
|
|
306
|
+
code = request.args.get('code')
|
|
307
|
+
provider = request.args.get('state')
|
|
308
|
+
|
|
309
|
+
if not code or not provider:
|
|
310
|
+
raise AuthError('Invalid OAuth callback', 400)
|
|
311
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
312
|
+
get_redirect_uri = self.get_redirect_uri()
|
|
313
|
+
parsed_uri = urlparse(get_redirect_uri)
|
|
314
|
+
frontend_url = os.getenv('FRONTEND_URL', urlunparse((parsed_uri.scheme, parsed_uri.netloc, '', '', '', '')))
|
|
315
|
+
|
|
316
|
+
#if provider == 'microsoft':
|
|
317
|
+
# client = msal.ConfidentialClientApplication(
|
|
318
|
+
# self.oauth_config[provider]['client_id'], client_credential=self.oauth_config[provider]['client_secret'], authority=f"https://login.microsoftonline.com/common"
|
|
319
|
+
# )
|
|
320
|
+
# result = client.acquire_token_by_authorization_code(code, scopes=["email"], redirect_uri=self.get_redirect_uri())
|
|
321
|
+
# code = result['access_token']
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
user_info = self._get_oauth_user_info(provider, code)
|
|
325
|
+
token = self._create_token(user_info)
|
|
326
|
+
refresh_token = self._create_refresh_token(user_info)
|
|
327
|
+
# Redirect to frontend with tokens
|
|
328
|
+
return redirect(f"{frontend_url}/oauth-callback?" + urlencode({'token': token, 'refresh_token': refresh_token}))
|
|
329
|
+
except AuthError as e:
|
|
330
|
+
# Surface error to frontend for user-friendly messaging
|
|
331
|
+
params = {
|
|
332
|
+
'error': str(e.message) if hasattr(e, 'message') else str(e),
|
|
333
|
+
'status': getattr(e, 'status_code', 500),
|
|
334
|
+
'provider': provider,
|
|
335
|
+
}
|
|
336
|
+
return redirect(f"{frontend_url}/oauth-callback?" + urlencode(params))
|
|
337
|
+
|
|
338
|
+
@bp.route('/login/profile')
|
|
339
|
+
def profile():
|
|
340
|
+
user = g.requesting_user
|
|
341
|
+
return jsonify(user)
|
|
342
|
+
|
|
343
|
+
@bp.route('/api-tokens', methods=['GET'])
|
|
344
|
+
def get_tokens():
|
|
345
|
+
tokens = self.get_user_api_tokens(g.requesting_user['id'])
|
|
346
|
+
return jsonify(tokens)
|
|
347
|
+
|
|
348
|
+
@bp.route('/api-tokens', methods=['POST'])
|
|
349
|
+
def create_token():
|
|
350
|
+
name = request.json.get('name')
|
|
351
|
+
expires_in_days = request.json.get('expires_in_days')
|
|
352
|
+
if not name:
|
|
353
|
+
raise AuthError('Token name is required', 400)
|
|
354
|
+
api_token = self.create_api_token(g.requesting_user['id'], name, expires_in_days)
|
|
355
|
+
return jsonify({
|
|
356
|
+
'id': api_token.id,
|
|
357
|
+
'name': api_token.name,
|
|
358
|
+
'token': api_token.get_full_token(),
|
|
359
|
+
'created_at': api_token.created_at,
|
|
360
|
+
'expires_at': api_token.expires_at
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
@bp.route('/token-refresh', methods=['POST'])
|
|
364
|
+
def refresh_token():
|
|
365
|
+
refresh_token = request.json.get('refresh_token')
|
|
366
|
+
if not refresh_token:
|
|
367
|
+
raise AuthError('No refresh token provided', 400)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
|
|
371
|
+
user_id = payload['sub']
|
|
372
|
+
|
|
373
|
+
with self.db.get_cursor() as cur:
|
|
374
|
+
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
375
|
+
user = cur.fetchone()
|
|
376
|
+
|
|
377
|
+
if not user:
|
|
378
|
+
raise AuthError('User not found', 404)
|
|
379
|
+
|
|
380
|
+
return jsonify({
|
|
381
|
+
'token': self._create_token(user),
|
|
382
|
+
'refresh_token': self._create_refresh_token(user)
|
|
383
|
+
})
|
|
384
|
+
except jwt.InvalidTokenError:
|
|
385
|
+
raise AuthError('Invalid refresh token', 401)
|
|
386
|
+
|
|
387
|
+
@bp.route('/api-tokens', methods=['POST'])
|
|
388
|
+
def create_api_token():
|
|
389
|
+
name = request.json.get('name')
|
|
390
|
+
if not name:
|
|
391
|
+
raise AuthError('Token name required', 400)
|
|
392
|
+
|
|
393
|
+
token = self.create_api_token(g.requesting_user['id'], name)
|
|
394
|
+
return jsonify({'token': token.token})
|
|
395
|
+
|
|
396
|
+
@bp.route('/api-tokens/validate', methods=['GET'])
|
|
397
|
+
def validate_api_token():
|
|
398
|
+
token = request.json.get('token')
|
|
399
|
+
if not token:
|
|
400
|
+
raise AuthError('No API token provided', 401)
|
|
401
|
+
token = ApiToken.parse_token_id(token)
|
|
402
|
+
|
|
403
|
+
with self.db.get_cursor() as cur:
|
|
404
|
+
cur.execute("""
|
|
405
|
+
SELECT * FROM api_tokens
|
|
406
|
+
WHERE user_id = %s AND id = %s
|
|
407
|
+
""", (g.requesting_user['id'], token))
|
|
408
|
+
api_token = cur.fetchone()
|
|
409
|
+
|
|
410
|
+
if not api_token:
|
|
411
|
+
raise AuthError('Invalid API token', 401)
|
|
412
|
+
|
|
413
|
+
# Check if token is expired
|
|
414
|
+
if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
|
|
415
|
+
raise AuthError('API token has expired', 401)
|
|
416
|
+
|
|
417
|
+
# Update last used timestamp
|
|
418
|
+
with self.db.get_cursor() as cur:
|
|
419
|
+
cur.execute("""
|
|
420
|
+
UPDATE api_tokens
|
|
421
|
+
SET last_used_at = %s
|
|
422
|
+
WHERE id = %s
|
|
423
|
+
""", (datetime.utcnow(), api_token['id']))
|
|
424
|
+
|
|
425
|
+
return jsonify({'valid': True})
|
|
426
|
+
|
|
427
|
+
@bp.route('/api-tokens', methods=['DELETE'])
|
|
428
|
+
def delete_api_token():
|
|
429
|
+
token = request.json.get('token')
|
|
430
|
+
if not token:
|
|
431
|
+
raise AuthError('Token required', 400)
|
|
432
|
+
token = ApiToken.parse_token_id(token)
|
|
433
|
+
|
|
434
|
+
with self.db.get_cursor() as cur:
|
|
435
|
+
cur.execute("""
|
|
436
|
+
DELETE FROM api_tokens
|
|
437
|
+
WHERE user_id = %s AND id = %s
|
|
438
|
+
RETURNING id
|
|
439
|
+
""", (g.requesting_user['id'], token))
|
|
440
|
+
deleted_id = cur.fetchone()
|
|
441
|
+
if not deleted_id:
|
|
442
|
+
raise ValueError('Token not found or already deleted')
|
|
443
|
+
|
|
444
|
+
return jsonify({'deleted': True})
|
|
445
|
+
|
|
446
|
+
@bp.route('/register', methods=['POST'])
|
|
447
|
+
def register():
|
|
448
|
+
data = request.get_json()
|
|
449
|
+
|
|
450
|
+
# Hash the password
|
|
451
|
+
password = data.get('password')
|
|
452
|
+
if not password:
|
|
453
|
+
raise AuthError('Password is required', 400)
|
|
454
|
+
|
|
455
|
+
salt = bcrypt.gensalt()
|
|
456
|
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
|
|
457
|
+
|
|
458
|
+
user = User(
|
|
459
|
+
username=data['username'],
|
|
460
|
+
email=data['email'],
|
|
461
|
+
real_name=data['real_name'],
|
|
462
|
+
roles=data.get('roles', []),
|
|
463
|
+
id_generator=self.db.get_id_generator()
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
with self.db.get_cursor() as cur:
|
|
467
|
+
if user.id is None:
|
|
468
|
+
cur.execute("""
|
|
469
|
+
INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
|
|
470
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
471
|
+
RETURNING id
|
|
472
|
+
""", (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
|
|
473
|
+
user.created_at, user.updated_at))
|
|
474
|
+
user.id = cur.fetchone()['id']
|
|
475
|
+
else:
|
|
476
|
+
cur.execute("""
|
|
477
|
+
INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
|
|
478
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
479
|
+
""", (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
|
|
480
|
+
user.created_at, user.updated_at))
|
|
481
|
+
|
|
482
|
+
return jsonify({'id': user.id}), 201
|
|
483
|
+
|
|
484
|
+
@bp.route('/roles', methods=['GET'])
|
|
485
|
+
def get_roles():
|
|
486
|
+
with self.db.get_cursor() as cur:
|
|
487
|
+
cur.execute("SELECT * FROM roles")
|
|
488
|
+
roles = cur.fetchall()
|
|
489
|
+
return jsonify(roles)
|
|
490
|
+
|
|
491
|
+
# Admin endpoints - require administrator role
|
|
492
|
+
@bp.route('/admin/users', methods=['GET'])
|
|
493
|
+
def admin_get_users():
|
|
494
|
+
self._require_admin_role()
|
|
495
|
+
with self.db.get_cursor() as cur:
|
|
496
|
+
cur.execute("""
|
|
497
|
+
SELECT u.*,
|
|
498
|
+
COALESCE(array_agg(r.name) FILTER (WHERE r.name IS NOT NULL), '{}') as roles
|
|
499
|
+
FROM users u
|
|
500
|
+
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
501
|
+
LEFT JOIN roles r ON ur.role_id = r.id
|
|
502
|
+
GROUP BY u.id, u.username, u.email, u.real_name, u.created_at, u.updated_at
|
|
503
|
+
ORDER BY u.created_at DESC
|
|
504
|
+
""")
|
|
505
|
+
users = cur.fetchall()
|
|
506
|
+
return jsonify(users)
|
|
507
|
+
|
|
508
|
+
@bp.route('/admin/users', methods=['POST'])
|
|
509
|
+
def admin_create_user():
|
|
510
|
+
self._require_admin_role()
|
|
511
|
+
data = request.get_json()
|
|
512
|
+
|
|
513
|
+
# Validate required fields
|
|
514
|
+
required_fields = ['username', 'email', 'real_name', 'password']
|
|
515
|
+
for field in required_fields:
|
|
516
|
+
if not data.get(field):
|
|
517
|
+
raise AuthError(f'{field} is required', 400)
|
|
518
|
+
|
|
519
|
+
# Hash the password
|
|
520
|
+
salt = bcrypt.gensalt()
|
|
521
|
+
password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
|
|
522
|
+
|
|
523
|
+
with self.db.get_cursor() as cur:
|
|
524
|
+
# Check if username or email already exists
|
|
525
|
+
cur.execute("SELECT id FROM users WHERE username = %s OR email = %s",
|
|
526
|
+
(data['username'], data['email']))
|
|
527
|
+
if cur.fetchone():
|
|
528
|
+
raise AuthError('Username or email already exists', 400)
|
|
529
|
+
|
|
530
|
+
# Create user
|
|
531
|
+
cur.execute("""
|
|
532
|
+
INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
|
|
533
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
534
|
+
RETURNING id
|
|
535
|
+
""", (data['username'], data['email'], data['real_name'],
|
|
536
|
+
password_hash.decode('utf-8'), datetime.utcnow(), datetime.utcnow()))
|
|
537
|
+
user_id = cur.fetchone()['id']
|
|
538
|
+
|
|
539
|
+
# Assign roles if provided
|
|
540
|
+
if data.get('roles'):
|
|
541
|
+
for role_name in data['roles']:
|
|
542
|
+
cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
|
|
543
|
+
role = cur.fetchone()
|
|
544
|
+
if role:
|
|
545
|
+
cur.execute("""
|
|
546
|
+
INSERT INTO user_roles (user_id, role_id)
|
|
547
|
+
VALUES (%s, %s)
|
|
548
|
+
ON CONFLICT (user_id, role_id) DO NOTHING
|
|
549
|
+
""", (user_id, role['id']))
|
|
550
|
+
|
|
551
|
+
return jsonify({'id': user_id}), 201
|
|
552
|
+
|
|
553
|
+
@bp.route('/admin/users/<user_id>', methods=['PUT'])
|
|
554
|
+
def admin_update_user(user_id):
|
|
555
|
+
self._require_admin_role()
|
|
556
|
+
data = request.get_json()
|
|
557
|
+
|
|
558
|
+
with self.db.get_cursor() as cur:
|
|
559
|
+
# Check if user exists
|
|
560
|
+
cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
|
|
561
|
+
if not cur.fetchone():
|
|
562
|
+
raise AuthError('User not found', 404)
|
|
563
|
+
|
|
564
|
+
# Update user fields
|
|
565
|
+
update_fields = []
|
|
566
|
+
update_values = []
|
|
567
|
+
|
|
568
|
+
if 'username' in data:
|
|
569
|
+
update_fields.append('username = %s')
|
|
570
|
+
update_values.append(data['username'])
|
|
571
|
+
if 'email' in data:
|
|
572
|
+
update_fields.append('email = %s')
|
|
573
|
+
update_values.append(data['email'])
|
|
574
|
+
if 'real_name' in data:
|
|
575
|
+
update_fields.append('real_name = %s')
|
|
576
|
+
update_values.append(data['real_name'])
|
|
577
|
+
if 'password' in data:
|
|
578
|
+
salt = bcrypt.gensalt()
|
|
579
|
+
password_hash = bcrypt.hashpw(data['password'].encode('utf-8'), salt)
|
|
580
|
+
update_fields.append('password_hash = %s')
|
|
581
|
+
update_values.append(password_hash.decode('utf-8'))
|
|
582
|
+
|
|
583
|
+
if update_fields:
|
|
584
|
+
update_fields.append('updated_at = %s')
|
|
585
|
+
update_values.append(datetime.utcnow())
|
|
586
|
+
update_values.append(user_id)
|
|
587
|
+
|
|
588
|
+
cur.execute(f"""
|
|
589
|
+
UPDATE users
|
|
590
|
+
SET {', '.join(update_fields)}
|
|
591
|
+
WHERE id = %s
|
|
592
|
+
""", update_values)
|
|
593
|
+
|
|
594
|
+
# Update roles if provided
|
|
595
|
+
if 'roles' in data:
|
|
596
|
+
# Remove existing roles
|
|
597
|
+
cur.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
|
|
598
|
+
|
|
599
|
+
# Add new roles
|
|
600
|
+
for role_name in data['roles']:
|
|
601
|
+
cur.execute("SELECT id FROM roles WHERE name = %s", (role_name,))
|
|
602
|
+
role = cur.fetchone()
|
|
603
|
+
if role:
|
|
604
|
+
cur.execute("""
|
|
605
|
+
INSERT INTO user_roles (user_id, role_id)
|
|
606
|
+
VALUES (%s, %s)
|
|
607
|
+
""", (user_id, role['id']))
|
|
608
|
+
|
|
609
|
+
return jsonify({'success': True})
|
|
610
|
+
|
|
611
|
+
@bp.route('/admin/users/<user_id>', methods=['DELETE'])
|
|
612
|
+
def admin_delete_user(user_id):
|
|
613
|
+
self._require_admin_role()
|
|
614
|
+
|
|
615
|
+
with self.db.get_cursor() as cur:
|
|
616
|
+
# Check if user exists
|
|
617
|
+
cur.execute("SELECT id FROM users WHERE id = %s", (user_id,))
|
|
618
|
+
if not cur.fetchone():
|
|
619
|
+
raise AuthError('User not found', 404)
|
|
620
|
+
|
|
621
|
+
# Delete user (cascade will handle related records)
|
|
622
|
+
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
|
623
|
+
|
|
624
|
+
return jsonify({'success': True})
|
|
625
|
+
|
|
626
|
+
@bp.route('/admin/roles', methods=['GET'])
|
|
627
|
+
def admin_get_roles():
|
|
628
|
+
self._require_admin_role()
|
|
629
|
+
with self.db.get_cursor() as cur:
|
|
630
|
+
cur.execute("SELECT * FROM roles ORDER BY name")
|
|
631
|
+
roles = cur.fetchall()
|
|
632
|
+
return jsonify(roles)
|
|
633
|
+
|
|
634
|
+
@bp.route('/admin/roles', methods=['POST'])
|
|
635
|
+
def admin_create_role():
|
|
636
|
+
self._require_admin_role()
|
|
637
|
+
data = request.get_json()
|
|
638
|
+
|
|
639
|
+
if not data.get('name'):
|
|
640
|
+
raise AuthError('Role name is required', 400)
|
|
641
|
+
|
|
642
|
+
with self.db.get_cursor() as cur:
|
|
643
|
+
# Check if role already exists
|
|
644
|
+
cur.execute("SELECT id FROM roles WHERE name = %s", (data['name'],))
|
|
645
|
+
if cur.fetchone():
|
|
646
|
+
raise AuthError('Role already exists', 400)
|
|
647
|
+
|
|
648
|
+
cur.execute("""
|
|
649
|
+
INSERT INTO roles (name, description, created_at)
|
|
650
|
+
VALUES (%s, %s, %s)
|
|
651
|
+
RETURNING id
|
|
652
|
+
""", (data['name'], data.get('description', ''), datetime.utcnow()))
|
|
653
|
+
role_id = cur.fetchone()['id']
|
|
654
|
+
|
|
655
|
+
return jsonify({'id': role_id}), 201
|
|
656
|
+
|
|
657
|
+
@bp.route('/admin/roles/<role_id>', methods=['PUT'])
|
|
658
|
+
def admin_update_role(role_id):
|
|
659
|
+
self._require_admin_role()
|
|
660
|
+
data = request.get_json()
|
|
661
|
+
|
|
662
|
+
with self.db.get_cursor() as cur:
|
|
663
|
+
# Check if role exists
|
|
664
|
+
cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
|
|
665
|
+
if not cur.fetchone():
|
|
666
|
+
raise AuthError('Role not found', 404)
|
|
667
|
+
|
|
668
|
+
update_fields = []
|
|
669
|
+
update_values = []
|
|
670
|
+
|
|
671
|
+
if 'name' in data:
|
|
672
|
+
update_fields.append('name = %s')
|
|
673
|
+
update_values.append(data['name'])
|
|
674
|
+
if 'description' in data:
|
|
675
|
+
update_fields.append('description = %s')
|
|
676
|
+
update_values.append(data['description'])
|
|
677
|
+
|
|
678
|
+
if update_fields:
|
|
679
|
+
update_values.append(role_id)
|
|
680
|
+
cur.execute(f"""
|
|
681
|
+
UPDATE roles
|
|
682
|
+
SET {', '.join(update_fields)}
|
|
683
|
+
WHERE id = %s
|
|
684
|
+
""", update_values)
|
|
685
|
+
|
|
686
|
+
return jsonify({'success': True})
|
|
687
|
+
|
|
688
|
+
@bp.route('/admin/roles/<role_id>', methods=['DELETE'])
|
|
689
|
+
def admin_delete_role(role_id):
|
|
690
|
+
self._require_admin_role()
|
|
691
|
+
|
|
692
|
+
with self.db.get_cursor() as cur:
|
|
693
|
+
# Check if role exists
|
|
694
|
+
cur.execute("SELECT id FROM roles WHERE id = %s", (role_id,))
|
|
695
|
+
if not cur.fetchone():
|
|
696
|
+
raise AuthError('Role not found', 404)
|
|
697
|
+
|
|
698
|
+
# Check if role is assigned to any users
|
|
699
|
+
cur.execute("SELECT COUNT(*) as count FROM user_roles WHERE role_id = %s", (role_id,))
|
|
700
|
+
count = cur.fetchone()['count']
|
|
701
|
+
if count > 0:
|
|
702
|
+
raise AuthError('Cannot delete role that is assigned to users', 400)
|
|
703
|
+
|
|
704
|
+
cur.execute("DELETE FROM roles WHERE id = %s", (role_id,))
|
|
705
|
+
|
|
706
|
+
return jsonify({'success': True})
|
|
707
|
+
|
|
708
|
+
@bp.route('/admin/api-tokens', methods=['GET'])
|
|
709
|
+
def admin_get_all_tokens():
|
|
710
|
+
self._require_admin_role()
|
|
711
|
+
with self.db.get_cursor() as cur:
|
|
712
|
+
cur.execute("""
|
|
713
|
+
SELECT t.*, u.username, u.email
|
|
714
|
+
FROM api_tokens t
|
|
715
|
+
JOIN users u ON t.user_id = u.id
|
|
716
|
+
ORDER BY t.created_at DESC
|
|
717
|
+
""")
|
|
718
|
+
tokens = cur.fetchall()
|
|
719
|
+
return jsonify(tokens)
|
|
720
|
+
|
|
721
|
+
@bp.route('/admin/api-tokens', methods=['POST'])
|
|
722
|
+
def admin_create_token():
|
|
723
|
+
self._require_admin_role()
|
|
724
|
+
data = request.get_json()
|
|
725
|
+
|
|
726
|
+
if not data.get('user_id') or not data.get('name'):
|
|
727
|
+
raise AuthError('user_id and name are required', 400)
|
|
728
|
+
|
|
729
|
+
expires_in_days = data.get('expires_in_days')
|
|
730
|
+
token = self.create_api_token(data['user_id'], data['name'], expires_in_days)
|
|
731
|
+
|
|
732
|
+
return jsonify({
|
|
733
|
+
'id': token.id,
|
|
734
|
+
'name': token.name,
|
|
735
|
+
'token': token.get_full_token(),
|
|
736
|
+
'created_at': token.created_at,
|
|
737
|
+
'expires_at': token.expires_at
|
|
738
|
+
}), 201
|
|
739
|
+
|
|
740
|
+
@bp.route('/admin/api-tokens/<token_id>', methods=['DELETE'])
|
|
741
|
+
def admin_delete_token(token_id):
|
|
742
|
+
self._require_admin_role()
|
|
743
|
+
|
|
744
|
+
with self.db.get_cursor() as cur:
|
|
745
|
+
cur.execute("DELETE FROM api_tokens WHERE id = %s", (token_id,))
|
|
746
|
+
if cur.rowcount == 0:
|
|
747
|
+
raise AuthError('Token not found', 404)
|
|
748
|
+
|
|
749
|
+
return jsonify({'success': True})
|
|
750
|
+
|
|
751
|
+
@bp.route('/admin/invite', methods=['POST'])
|
|
752
|
+
def admin_send_invitation():
|
|
753
|
+
self._require_admin_role()
|
|
754
|
+
data = request.get_json()
|
|
755
|
+
|
|
756
|
+
if not data.get('email'):
|
|
757
|
+
raise AuthError('Email is required', 400)
|
|
758
|
+
|
|
759
|
+
# Check if user already exists
|
|
760
|
+
with self.db.get_cursor() as cur:
|
|
761
|
+
cur.execute("SELECT id FROM users WHERE email = %s", (data['email'],))
|
|
762
|
+
if cur.fetchone():
|
|
763
|
+
raise AuthError('User with this email already exists', 400)
|
|
764
|
+
|
|
765
|
+
# Send invitation email (placeholder - implement actual email sending)
|
|
766
|
+
invitation_token = str(uuid.uuid4())
|
|
767
|
+
|
|
768
|
+
# Store invitation in database (you might want to create an invitations table)
|
|
769
|
+
# For now, we'll just return success
|
|
770
|
+
return jsonify({
|
|
771
|
+
'success': True,
|
|
772
|
+
'message': f'Invitation sent to {data["email"]}',
|
|
773
|
+
'invitation_token': invitation_token
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
return bp
|
|
777
|
+
|
|
778
|
+
def validate_token(self, token):
|
|
779
|
+
try:
|
|
780
|
+
logger.debug(f"Validating token: {token}")
|
|
781
|
+
payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
|
|
782
|
+
logger.debug(f"Token payload: {payload}")
|
|
783
|
+
user_id = int(payload['sub']) # Convert string ID back to integer
|
|
784
|
+
|
|
785
|
+
# Check cache first
|
|
786
|
+
cache_key = f"user_{user_id}"
|
|
787
|
+
current_time = datetime.utcnow()
|
|
788
|
+
|
|
789
|
+
if cache_key in self._user_cache:
|
|
790
|
+
cached_data, cache_time = self._user_cache[cache_key]
|
|
791
|
+
if (current_time - cache_time).total_seconds() < self._cache_ttl:
|
|
792
|
+
logger.debug(f"Returning cached user data for ID: {user_id}")
|
|
793
|
+
return cached_data.copy() # Return a copy to avoid modifying cache
|
|
794
|
+
|
|
795
|
+
# Cache miss or expired, fetch from database
|
|
796
|
+
with self.db.get_cursor() as cur:
|
|
797
|
+
cur.execute("""
|
|
798
|
+
SELECT u.*, r.name as role_name FROM users u
|
|
799
|
+
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
|
800
|
+
LEFT JOIN roles r ON ur.role_id = r.id
|
|
801
|
+
WHERE u.id = %s
|
|
802
|
+
""", (user_id,))
|
|
803
|
+
results = cur.fetchall()
|
|
804
|
+
if not results:
|
|
805
|
+
logger.error(f"User not found for ID: {user_id}")
|
|
806
|
+
raise AuthError('User not found', 404)
|
|
807
|
+
|
|
808
|
+
# Get the first row for user data (all rows will have same user data)
|
|
809
|
+
user = results[0]
|
|
810
|
+
|
|
811
|
+
# Extract roles from results
|
|
812
|
+
roles = [row['role_name'] for row in results if row['role_name'] is not None]
|
|
813
|
+
user['roles'] = roles
|
|
814
|
+
|
|
815
|
+
# Cache the result
|
|
816
|
+
self._user_cache[cache_key] = (user.copy(), current_time)
|
|
817
|
+
|
|
818
|
+
# Clean up expired cache entries
|
|
819
|
+
self._cleanup_cache()
|
|
820
|
+
|
|
821
|
+
return user
|
|
822
|
+
except jwt.InvalidTokenError as e:
|
|
823
|
+
logger.error(f"Invalid token error: {str(e)}")
|
|
824
|
+
raise AuthError('Invalid token', 401)
|
|
825
|
+
except Exception as e:
|
|
826
|
+
logger.error(f"Unexpected error during token validation: {str(e)}")
|
|
827
|
+
raise AuthError(str(e), 500)
|
|
828
|
+
|
|
829
|
+
def _cleanup_cache(self):
|
|
830
|
+
"""Remove expired cache entries."""
|
|
831
|
+
current_time = datetime.utcnow()
|
|
832
|
+
expired_keys = [
|
|
833
|
+
key for key, (_, cache_time) in self._user_cache.items()
|
|
834
|
+
if (current_time - cache_time).total_seconds() >= self._cache_ttl
|
|
835
|
+
]
|
|
836
|
+
for key in expired_keys:
|
|
837
|
+
del self._user_cache[key]
|
|
838
|
+
|
|
839
|
+
def _start_update_thread(self):
|
|
840
|
+
"""Start the background thread for processing last_used_at updates."""
|
|
841
|
+
if self._update_thread is None or not self._update_thread.is_alive():
|
|
842
|
+
self._update_thread = threading.Thread(target=self._update_worker, daemon=True)
|
|
843
|
+
self._update_thread.start()
|
|
844
|
+
logger.debug("Started background update thread")
|
|
845
|
+
|
|
846
|
+
def _schedule_last_used_update(self, token_id):
|
|
847
|
+
"""Schedule a last_used_at update for an API token with 10s delay."""
|
|
848
|
+
with self._update_lock:
|
|
849
|
+
self._last_used_updates[token_id] = time.time()
|
|
850
|
+
logger.debug(f"Scheduled last_used update for token {token_id}")
|
|
851
|
+
|
|
852
|
+
def _update_worker(self):
|
|
853
|
+
"""Background worker that processes last_used_at updates."""
|
|
854
|
+
while not self._shutdown_event.is_set():
|
|
855
|
+
try:
|
|
856
|
+
current_time = time.time()
|
|
857
|
+
tokens_to_update = []
|
|
858
|
+
|
|
859
|
+
# Collect tokens that need updating (older than 10 seconds)
|
|
860
|
+
with self._update_lock:
|
|
861
|
+
for token_id, schedule_time in list(self._last_used_updates.items()):
|
|
862
|
+
if current_time - schedule_time >= 10: # 10 second delay
|
|
863
|
+
tokens_to_update.append(token_id)
|
|
864
|
+
del self._last_used_updates[token_id]
|
|
865
|
+
|
|
866
|
+
# Perform batch update
|
|
867
|
+
if tokens_to_update:
|
|
868
|
+
self._perform_batch_update(tokens_to_update)
|
|
869
|
+
|
|
870
|
+
# Sleep for a short interval
|
|
871
|
+
time.sleep(10)
|
|
872
|
+
|
|
873
|
+
except Exception as e:
|
|
874
|
+
logger.error(f"Error in update worker: {e}")
|
|
875
|
+
time.sleep(5) # Wait longer on error
|
|
876
|
+
|
|
877
|
+
def _perform_batch_update(self, token_ids):
|
|
878
|
+
"""Perform batch update of last_used_at for multiple tokens."""
|
|
879
|
+
try:
|
|
880
|
+
with self.db.get_cursor() as cur:
|
|
881
|
+
# Update all tokens in a single query
|
|
882
|
+
placeholders = ','.join(['%s'] * len(token_ids))
|
|
883
|
+
cur.execute(f"""
|
|
884
|
+
UPDATE api_tokens
|
|
885
|
+
SET last_used_at = %s
|
|
886
|
+
WHERE id IN ({placeholders})
|
|
887
|
+
""", [datetime.utcnow()] + token_ids)
|
|
888
|
+
|
|
889
|
+
logger.debug(f"Updated last_used_at for {len(token_ids)} tokens: {token_ids}")
|
|
890
|
+
|
|
891
|
+
except Exception as e:
|
|
892
|
+
logger.error(f"Error performing batch update: {e}")
|
|
893
|
+
|
|
894
|
+
def shutdown(self):
|
|
895
|
+
"""Shutdown the background update thread."""
|
|
896
|
+
self._shutdown_event.set()
|
|
897
|
+
if self._update_thread and self._update_thread.is_alive():
|
|
898
|
+
self._update_thread.join(timeout=5)
|
|
899
|
+
logger.debug("Background update thread shutdown complete")
|
|
900
|
+
|
|
901
|
+
def get_current_user(self):
|
|
902
|
+
return self._authenticate_request()
|
|
903
|
+
|
|
904
|
+
def _require_admin_role(self):
|
|
905
|
+
"""Require the current user to have administrator role."""
|
|
906
|
+
user = g.requesting_user
|
|
907
|
+
if not user or 'administrator' not in user.get('roles', []):
|
|
908
|
+
raise AuthError('Administrator role required', 403)
|
|
909
|
+
|
|
910
|
+
def get_user_api_tokens(self, user_id):
|
|
911
|
+
"""Get all API tokens for a user."""
|
|
912
|
+
with self.db.get_cursor() as cur:
|
|
913
|
+
cur.execute("""
|
|
914
|
+
SELECT id, name, created_at, expires_at, last_used_at
|
|
915
|
+
FROM api_tokens
|
|
916
|
+
WHERE user_id = %s
|
|
917
|
+
ORDER BY created_at DESC
|
|
918
|
+
""", (user_id,))
|
|
919
|
+
return cur.fetchall()
|
|
920
|
+
|
|
921
|
+
def create_api_token(self, user_id, name, expires_in_days=None):
|
|
922
|
+
"""Create a new API token for a user."""
|
|
923
|
+
token = ApiToken(user_id, name, expires_in_days)
|
|
924
|
+
|
|
925
|
+
with self.db.get_cursor() as cur:
|
|
926
|
+
cur.execute("""
|
|
927
|
+
INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
|
|
928
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
929
|
+
""", (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
|
|
930
|
+
return token
|
|
931
|
+
|
|
932
|
+
def _create_token(self, user):
|
|
933
|
+
payload = {
|
|
934
|
+
'sub': str(user['id']),
|
|
935
|
+
'exp': datetime.utcnow() + self.expiry_time,
|
|
936
|
+
'iat': datetime.utcnow()
|
|
937
|
+
}
|
|
938
|
+
logger.debug(f"Creating token with payload: {payload}")
|
|
939
|
+
token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
|
|
940
|
+
logger.info(f"Created token: {token}")
|
|
941
|
+
return token
|
|
942
|
+
|
|
943
|
+
def _create_refresh_token(self, user):
|
|
944
|
+
payload = {
|
|
945
|
+
'sub': str(user['id']),
|
|
946
|
+
'exp': datetime.utcnow() + timedelta(days=30),
|
|
947
|
+
'iat': datetime.utcnow()
|
|
948
|
+
}
|
|
949
|
+
return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
|
|
950
|
+
|
|
951
|
+
def _verify_password(self, password, password_hash):
|
|
952
|
+
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
|
953
|
+
|
|
954
|
+
def _get_oauth_url(self, provider, redirect_uri):
|
|
955
|
+
meta = self._get_provider_meta(provider)
|
|
956
|
+
client_id = self.oauth_config[provider]['client_id']
|
|
957
|
+
scope = self.oauth_config[provider].get('scope', meta['default_scope'])
|
|
958
|
+
state = provider # Pass provider as state for callback
|
|
959
|
+
# Some providers require additional params
|
|
960
|
+
params = {
|
|
961
|
+
'client_id': client_id,
|
|
962
|
+
'redirect_uri': redirect_uri,
|
|
963
|
+
'response_type': 'code',
|
|
964
|
+
'scope': scope,
|
|
965
|
+
'state': state
|
|
966
|
+
}
|
|
967
|
+
# Facebook requires display; GitHub supports prompt
|
|
968
|
+
if provider == 'facebook':
|
|
969
|
+
params['display'] = 'page'
|
|
970
|
+
# Build URL
|
|
971
|
+
from urllib.parse import urlencode
|
|
972
|
+
return f"{meta['auth_url']}?{urlencode(params)}"
|
|
973
|
+
|
|
974
|
+
def _get_oauth_user_info(self, provider, code):
|
|
975
|
+
meta = self._get_provider_meta(provider)
|
|
976
|
+
client_id = self.oauth_config[provider]['client_id']
|
|
977
|
+
client_secret = self.oauth_config[provider]['client_secret']
|
|
978
|
+
redirect_uri = self.get_redirect_uri()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
if provider == 'microsoft':
|
|
982
|
+
import msal
|
|
983
|
+
client = msal.ConfidentialClientApplication(
|
|
984
|
+
client_id,
|
|
985
|
+
client_credential=client_secret,
|
|
986
|
+
authority="https://login.microsoftonline.com/common"
|
|
987
|
+
)
|
|
988
|
+
tokens = client.acquire_token_by_authorization_code(
|
|
989
|
+
code,
|
|
990
|
+
scopes=["email"],
|
|
991
|
+
redirect_uri=redirect_uri
|
|
992
|
+
)
|
|
993
|
+
else:
|
|
994
|
+
# Standard OAuth flow for other providers
|
|
995
|
+
token_data = {
|
|
996
|
+
'client_id': client_id,
|
|
997
|
+
'client_secret': client_secret,
|
|
998
|
+
'code': code,
|
|
999
|
+
'grant_type': 'authorization_code',
|
|
1000
|
+
'redirect_uri': redirect_uri,
|
|
1001
|
+
'scope': meta['default_scope']
|
|
1002
|
+
}
|
|
1003
|
+
token_headers = {}
|
|
1004
|
+
if provider == 'github':
|
|
1005
|
+
token_headers['Accept'] = 'application/json'
|
|
1006
|
+
token_response = requests.post(meta['token_url'], data=token_data, headers=token_headers)
|
|
1007
|
+
logger.info("TOKEN RESPONSE: {} {} {} [[[{}]]]".format(token_response.text, token_response.status_code, token_response.headers, token_data))
|
|
1008
|
+
token_response.raise_for_status()
|
|
1009
|
+
tokens = token_response.json()
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
access_token = tokens.get('access_token') or tokens.get('id_token')
|
|
1013
|
+
if not access_token:
|
|
1014
|
+
# Some providers return id_token separately but require access_token for userinfo
|
|
1015
|
+
access_token = tokens.get('access_token')
|
|
1016
|
+
|
|
1017
|
+
# Build userinfo request
|
|
1018
|
+
userinfo_url = meta['userinfo_url']
|
|
1019
|
+
userinfo_headers = {'Authorization': f"Bearer {access_token}"}
|
|
1020
|
+
if provider == 'facebook':
|
|
1021
|
+
# Ensure fields
|
|
1022
|
+
from urllib.parse import urlencode
|
|
1023
|
+
userinfo_url = f"{userinfo_url}?{urlencode({'fields': 'id,name,email'})}"
|
|
1024
|
+
|
|
1025
|
+
userinfo_response = requests.get(userinfo_url, headers=userinfo_headers)
|
|
1026
|
+
userinfo_response.raise_for_status()
|
|
1027
|
+
raw_userinfo = userinfo_response.json()
|
|
1028
|
+
|
|
1029
|
+
# Special handling for GitHub missing email
|
|
1030
|
+
if provider == 'github' and not raw_userinfo.get('email'):
|
|
1031
|
+
emails_resp = requests.get('https://api.github.com/user/emails', headers={**userinfo_headers, 'Accept': 'application/vnd.github+json'})
|
|
1032
|
+
if emails_resp.ok:
|
|
1033
|
+
emails = emails_resp.json()
|
|
1034
|
+
primary = next((e for e in emails if e.get('primary') and e.get('verified')), None)
|
|
1035
|
+
raw_userinfo['email'] = (primary or (emails[0] if emails else {})).get('email')
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
# Normalize
|
|
1041
|
+
norm = self._normalize_userinfo(provider, raw_userinfo)
|
|
1042
|
+
if not norm.get('email'):
|
|
1043
|
+
# Fallback pseudo-email if allowed
|
|
1044
|
+
norm['email'] = f"{norm['sub']}@{provider}.local"
|
|
1045
|
+
|
|
1046
|
+
# Create or update user
|
|
1047
|
+
with self.db.get_cursor() as cur:
|
|
1048
|
+
cur.execute("SELECT * FROM users WHERE email = %s", (norm['email'],))
|
|
1049
|
+
user = cur.fetchone()
|
|
1050
|
+
|
|
1051
|
+
if not user:
|
|
1052
|
+
if not self.allow_oauth_auto_create:
|
|
1053
|
+
raise AuthError('User not found and auto-create disabled', 403)
|
|
1054
|
+
# Create new user (auto-create enabled)
|
|
1055
|
+
user_obj = User(
|
|
1056
|
+
username=norm['email'],
|
|
1057
|
+
email=norm['email'],
|
|
1058
|
+
real_name=norm.get('name', norm['email']),
|
|
1059
|
+
id_generator=self.db.get_id_generator()
|
|
1060
|
+
)
|
|
1061
|
+
cur.execute("""
|
|
1062
|
+
INSERT INTO users (username, email, real_name, created_at, updated_at)
|
|
1063
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
1064
|
+
RETURNING id
|
|
1065
|
+
""", (user_obj.username, user_obj.email, user_obj.real_name,
|
|
1066
|
+
user_obj.created_at, user_obj.updated_at))
|
|
1067
|
+
new_id = cur.fetchone()['id']
|
|
1068
|
+
user = {'id': new_id, 'username': user_obj.username, 'email': user_obj.email,
|
|
1069
|
+
'real_name': user_obj.real_name, 'roles': []}
|
|
1070
|
+
else:
|
|
1071
|
+
# Update existing user
|
|
1072
|
+
cur.execute("""
|
|
1073
|
+
UPDATE users
|
|
1074
|
+
SET real_name = %s, updated_at = %s
|
|
1075
|
+
WHERE email = %s
|
|
1076
|
+
""", (norm.get('name', norm['email']), datetime.utcnow(), norm['email']))
|
|
1077
|
+
user['real_name'] = norm.get('name', norm['email'])
|
|
1078
|
+
|
|
1079
|
+
return user
|
|
1080
|
+
|
|
1081
|
+
def _get_provider_meta(self, provider):
|
|
1082
|
+
providers = {
|
|
1083
|
+
'google': {
|
|
1084
|
+
'auth_url': 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
1085
|
+
'token_url': 'https://oauth2.googleapis.com/token',
|
|
1086
|
+
'userinfo_url': 'https://www.googleapis.com/oauth2/v3/userinfo',
|
|
1087
|
+
'default_scope': 'openid email profile'
|
|
1088
|
+
},
|
|
1089
|
+
'github': {
|
|
1090
|
+
'auth_url': 'https://github.com/login/oauth/authorize',
|
|
1091
|
+
'token_url': 'https://github.com/login/oauth/access_token',
|
|
1092
|
+
'userinfo_url': 'https://api.github.com/user',
|
|
1093
|
+
'default_scope': 'read:user user:email'
|
|
1094
|
+
},
|
|
1095
|
+
'facebook': {
|
|
1096
|
+
'auth_url': 'https://www.facebook.com/v11.0/dialog/oauth',
|
|
1097
|
+
'token_url': 'https://graph.facebook.com/v11.0/oauth/access_token',
|
|
1098
|
+
'userinfo_url': 'https://graph.facebook.com/me',
|
|
1099
|
+
'default_scope': 'email public_profile'
|
|
1100
|
+
},
|
|
1101
|
+
'microsoft': {
|
|
1102
|
+
'auth_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
1103
|
+
'token_url': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
1104
|
+
'userinfo_url': 'https://graph.microsoft.com/oidc/userinfo',
|
|
1105
|
+
'default_scope': 'openid email profile'
|
|
1106
|
+
},
|
|
1107
|
+
'linkedin': {
|
|
1108
|
+
'auth_url': 'https://www.linkedin.com/oauth/v2/authorization',
|
|
1109
|
+
'token_url': 'https://www.linkedin.com/oauth/v2/accessToken',
|
|
1110
|
+
'userinfo_url': 'https://api.linkedin.com/v2/userinfo',
|
|
1111
|
+
'default_scope': 'openid profile email'
|
|
1112
|
+
},
|
|
1113
|
+
'slack': {
|
|
1114
|
+
'auth_url': 'https://slack.com/openid/connect/authorize',
|
|
1115
|
+
'token_url': 'https://slack.com/api/openid.connect.token',
|
|
1116
|
+
'userinfo_url': 'https://slack.com/api/openid.connect.userInfo',
|
|
1117
|
+
'default_scope': 'openid profile email'
|
|
1118
|
+
},
|
|
1119
|
+
'apple': {
|
|
1120
|
+
'auth_url': 'https://appleid.apple.com/auth/authorize',
|
|
1121
|
+
'token_url': 'https://appleid.apple.com/auth/token',
|
|
1122
|
+
'userinfo_url': 'https://appleid.apple.com/auth/userinfo',
|
|
1123
|
+
'default_scope': 'name email'
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if provider not in providers:
|
|
1127
|
+
raise AuthError('Invalid OAuth provider ' + provider)
|
|
1128
|
+
return providers[provider]
|
|
1129
|
+
|
|
1130
|
+
def _normalize_userinfo(self, provider, info):
|
|
1131
|
+
# Map into a common structure: sub, email, name
|
|
1132
|
+
if provider == 'google':
|
|
1133
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
1134
|
+
if provider == 'github':
|
|
1135
|
+
return {'sub': str(info.get('id')), 'email': info.get('email'), 'name': info.get('name') or info.get('login')}
|
|
1136
|
+
if provider == 'facebook':
|
|
1137
|
+
return {'sub': info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
|
|
1138
|
+
if provider == 'microsoft':
|
|
1139
|
+
# OIDC userinfo
|
|
1140
|
+
return {'sub': info.get('sub') or info.get('oid'), 'email': info.get('email') or info.get('preferred_username'), 'name': info.get('name')}
|
|
1141
|
+
if provider == 'linkedin':
|
|
1142
|
+
return {'sub': info.get('sub') or info.get('id'), 'email': info.get('email'), 'name': info.get('name')}
|
|
1143
|
+
if provider == 'slack':
|
|
1144
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
1145
|
+
if provider == 'apple':
|
|
1146
|
+
# Apple email may be private relay; name not always present
|
|
1147
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|
|
1148
|
+
return {'sub': info.get('sub'), 'email': info.get('email'), 'name': info.get('name')}
|