From 7d50148e6e3cc846f7f0e4d3af1be7f1fe661b3c Mon Sep 17 00:00:00 2001 From: Vansh Date: Sun, 12 Jul 2026 13:01:24 +0530 Subject: [PATCH] feat: improve error handling and lifecycle management for tabCapture --- backend/backend/settings.py | 43 ++++- backend/backend/urls.py | 6 +- backend/db.sqlite3 | Bin 475136 -> 581632 bytes backend/tenants/security.py | 1 + backend/users/jwt.py | 31 ++++ ...mail_verified_user_mfa_enabled_and_more.py | 28 ++++ backend/users/models.py | 3 + backend/users/serializers.py | 10 ++ backend/users/tests.py | 153 ++++++++++++++++++ backend/users/throttling.py | 24 +++ backend/users/validators.py | 32 ++++ backend/users/views.py | 112 ++++++++++++- requirements.txt | 1 + 13 files changed, 431 insertions(+), 13 deletions(-) create mode 100644 backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py create mode 100644 backend/users/throttling.py create mode 100644 backend/users/validators.py diff --git a/backend/backend/settings.py b/backend/backend/settings.py index aeb0cef..26bd8a8 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -57,10 +57,14 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'fallback-insecure-key-for-local-dev-and-testing' if (DEBUG or TESTING) else '', ) ALLOWED_HOSTS = ['*'] -CORS_ALLOWED_ORIGINS = [ - "http://localhost:3000", - "http://127.0.0.1:3000", -] +ADMIN_PATH = os.getenv('ADMIN_PATH', 'secure-admin-portal') + +# Cookie Security +SESSION_COOKIE_SECURE = not DEBUG +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' +CSRF_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SAMESITE = 'Lax' INSTALLED_APPS = [ 'django.contrib.admin', @@ -71,6 +75,7 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'django_celery_beat', 'tenants', @@ -124,9 +129,13 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': {'min_length': 10}, + }, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, + {'NAME': 'users.validators.ComplexityPasswordValidator'}, ] LANGUAGE_CODE = 'en-us' @@ -142,7 +151,16 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', - ) + ), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '1000/day', + 'login': '5/minute', + } } # Celery Configuration @@ -179,10 +197,19 @@ def _normalize_google_redirect_uri(raw_uri: str, backend_base_url: str) -> str: 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'USER_ID_FIELD': 'id', 'USER_ID_CLAIM': 'user_id', + 'SIGNING_KEY': os.getenv('JWT_SIGNING_KEY', SECRET_KEY), } -# Allow all origins in development -CORS_ALLOW_ALL_ORIGINS = True +# Restrict CORS to trusted origins +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8080", + "http://127.0.0.1:8080", +] +if os.getenv('FRONTEND_BASE_URL'): + CORS_ALLOWED_ORIGINS.append(os.getenv('FRONTEND_BASE_URL').rstrip('/')) # Gemini API Key GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', _read_local_env_value('GEMINI_API_KEY', '')) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 82f6b7a..41628dd 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -6,8 +6,12 @@ from rest_framework_simplejwt.views import TokenObtainPairView as BaseTokenObtainPairView from users.jwt import CustomTokenObtainSerializer +from django.conf import settings +from users.throttling import LoginRateThrottle + class CustomTokenObtainPairView(BaseTokenObtainPairView): serializer_class = CustomTokenObtainSerializer + throttle_classes = [LoginRateThrottle] from users.views import AuthViewSet from leads.views import BlockedDomainViewSet, LeadImportJobViewSet, LeadViewSet, TagViewSet @@ -49,7 +53,7 @@ def api_root(_request): urlpatterns = [ path('', api_root, name='api_root'), - path('admin/', admin.site.urls), + path(f'{settings.ADMIN_PATH}/', admin.site.urls), path('api/v1/token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/v1/webhooks/email/', WebhookView.as_view(), name='email_webhook'), diff --git a/backend/db.sqlite3 b/backend/db.sqlite3 index e43ec6b499da3f29c5629f66e7a8b3edd2d08dc9..f2cd4fbbcba68d8503de6aa9ef5d7d210972f385 100644 GIT binary patch delta 19086 zcmeHv33yx8weHzx(s<61WXrPSIFcP3$8l^+mStOUCgV7Xh+Db@@WCx}ey=Wyu)TPK~)6KqyN{+I()VBM(d;Oj3d-|PDr`MI(Qf zd~LzOp7wyRJ22qy@DKRQo#mA!&Z-huxx-mG)m1*V+*?}i_Ec7QmU%!_cd{s)dw|Xe z`Pv8j2ZG(cZhvQ2TX3syU1y-Hqd%1-SCzNI?VeQulBOp~s)h>mtq=GJle5z0^p%&p z++!tFmsXcM-Cp+`H%MrRNeC(Js`B}}1_FJ_t?M7~4-BTL3J7v}+}?_sauDQBs!EkF zKpNs32z2*$`3C~2YEk9&RC(PiTpX1>SbMR^@^c{<#$bD;AbpS0i62tZAEc|2S2`=5 zlA5JHDP8U7h^{zTn^h)V8OivuC{w zP0V1Lv|mVMO+gfw8R(Gt(-@{0`3DC!_QtJovfoWOIS)t{~`TWdS09>P7yv3mWpO*Q=il*O%$(-uZxcfw~M=l zez8kjBb^fO5q=_kT|9EpwJ=8rWwZ7|q)xPcxkuFw&9bq!LPRECKS?^fdxL!g8-s0` zBl&Gz!S+poj*cLVM2{_>EiOT$dSY~MWh4{XlujMp6V{6#thUii2Y)uAS$let_N?}} zwoiMjwnm$yxvqIf^StJ~=1$E{O^2pIQ>wA@pYd<=KjzOuliuV)nJjXltSy}|cuDQ) z7{?+H%IXMo1qK4iP`d)PZ-D+C1-02&REDzr9UaLmez}-TxtOHx39XfdWyZ=$!h8x8 zD<=tBBbSp&%1O#!Etg}H%Sr0)RkAEwte6of1Vgb>!D8i%U;wR<%dwGilJb{lv#1JX z$mNV4v}HLgszDjCqDG@j6*N{@Ff?j?+$z^4gA_QLbBSE0HP*J+0*ft%TV$!$c=M9c z#R?j4U;=7~<=QHjX;m7TfC04V;!ii!XqYVF)`+IJNH6?YctAKT^a;1ZpBJ0{DlYkg1u@Z@NMwsucJzX{`2MbRx=b8<4&f8wSHcD14q?4;vyfx_FXJn4ybND_d{4F! z`|qankuh?5UOu+(MqDJl!NE!SOcTOa-{T!9BNA%JM?5}#h{;D<+#Nzikw49MAVcKD z%zTcU$TaC97p6I|<8wYA>2NfJ3L>Z?A88`Lh180#{s#E;xcfT54Bpepw6+4uG|XGa}FL{h4XAr~+jQ z2LYbi_o68%r~q&fQ3Ns52Pu5#ZLgTaeHs;UDJ;)~e{UM#)l82n=RMOFPR&fHPS@?PnEF{2 zaI4fAk&ZjAnz|VkC>#NnA9>EyL#seSljj|M-qeLupwJrcr0QcoqBofyy`MEP9m1K2 z=KSeNqFu}q(?mhkhzRE1pM(#EKM3y#ZwS8@UKXAgo)xYLPY4%0&u;mD5Ee zO<%}eoO1aWwO^$T%tq;xJ4JMtNEf2_(FL>})gwLi(uvxvkasCZp_=*|ZFP129*43c zITH3VhrGnSw<@gaJ~(O{B&-TFmesukSUoc{{Gk+^Bw>V&Y%G`O7T~I?O}%{*F0!vW z5_XbUJr~RMOu4g$V1rC(<5=ESg*Vl{8)J>NaSW?_k=(|Vd$QWZHj}Dl1v-?ZLn1==7IE2P#ZL9}fioA&2gtg-cF^9A$JHC8EiclrR3=_;bSt z+B19|cPIO*X1~Fv?`3Ce7NW@`e>C%8&wnp!TZ9@Bt8t>Yqw8AH-5O1wEjJgrvmy)i zg}7`5GP1_UDNl6f3iN%hh%TiJRDja{O0$c9mtVj&v&Wdr!e>IO@d{**{F#i`9H48W zfwkzTh}YJkn61#QU6>MPOeXrK+{Blt>rFvuQ*og*bAZ|Frzi8TGQIQ&Dw$tUu`;3yFm2Q(-BuRWxjEKv`9N{(Le$XL` zjUO7L#+}A0!+#kbGOREd^v~);dav$Jx~FvebaQo*_Brh_ZKKwrc};V#CZKWf|H^-x z@8c`E&tMGmAKOibI6QtU?PY7&CNKVc7s|!Y57FcC<{{d{Rk2NOT?EZ^;15D{nnL2} zW^(as+i4qqc`IGd0*Nbfx+)i6-b$z8mTmMPTMb-tR?Rj#ovmY=8suNwx6*d(+ez23 zv)QJ`NbQ1L{MX&I1K+!wu9ij1SzAN&LVPSl--Kt8B4utux_dWmXX@Fe$jdc(_}jba zGi)>4on^HdcCGCXrFYKVb_@nLgYK?g7*dd{lT6*$3S4~z$j>7TWzvdJfI%bEu2adk@fi7*^<~>Z7@b=~f2pK=ctjbcDVgv~KjdEnr9C^?272 zm}SLB>Dx%=0^8zT6!zjp$6yY;d6e!19_k6)c#M7`$~w`f_ODzu=!zk)06zI(S9o7i z)|^ROrmK{-cy&~>Cq800VDKBJ!G`!p{R{eU>0_g;`%w3s?i;#2x|O<081W`LdXZ-K z@rp8^Ef0JS8F*wT>U-q)OfIso!GTW%na0T8`S~cFOaNpfIy_$Y22+eLbu&*yPR_J* zygXCx(k6zJzqEHTE%KN1_tISC9e`GJGLz-G zf-;E0-$HbKI~q^a{CxZ$oy<+^$c?4UaFU9f&fb&WmfnzFm0povlzuAxP`V;LDLp2g zmmZNGluk=KrKM82B#M6!e<JY^g(wiqWH zIm26q?;1`*$6VD$E3=rjH1;=fP<7YU8@VF<&y8FGE^p=vR+h00g-S^>nIv|SG=m#CltW2WhaMF8_QcFgwNG*MdlKYW^FtkGZ6%oRFqn9>lq&bGf^g`)spLzYsW00mk5cQrLBe9^CI z7&~pL;uK(Si^_r5P6mN*>KF@33om44*cKisZ+eJ(I&JR^h5$KPdjoO4+!Dr$e=jm~ z@p1$6^R&Gtnvl4%!h>;=T8U1_zZRJH)Aor-DR|%Xc#W!!OaKwvY4;RJ+8vOr$%G_8 zZivgZmwFj?R-TCJDD;4oBd!y^3s+l@;gJ4L-3z)dZJTB`9RP3>96BX<{dp!c`r9Yj zYJ_K8VZP7awrYh2@4m?18)*@w=)oVdJ_ge-uvyXP9%K4-m!4+OM@3MIga#=zC>4l{ zh3&=%3_1D^eWC8Sc8%7`xAF`(jc!6^)D9?N5gT@fv++F#=mzA%=gzZP_^t0U8vNt| zIt$wm(j`n?cpSDLrf){eFx2B!!~RX-EDhOrZb$OIgJIvXpenrU0@NZjOuval*wtji zg0CIyyaRYv7qaU613d#HzcX~bV22!m4Sj*$u5Ae!LVH&yvBo82g_NB%mX+t|>=_8G z5A->jTW*mz%%bq@pvKl+gTmdNJsp9qal1#dt;-keTkr4b+%6j(eVrXXceSUg%GK_< z0sT;2Yklo4^^OJ{JJ-p3dgL5ykd4Xi1t{d=e z**B|OW2>n_2W$iWwywYp$Zn85NJc2rlvGrnV-o(|MzDQI%&f?dr3owh2irCV+6UBQ zDAu#`Db=xUY;HT4s@Dhm08tM%j}uO(5w^j8KiDAS;~c{Oet~U>W`Bn*M>yjt7V|Y7 zysf23QCqLP#A@*E-(xfJnWxw%^!w;Gjcw5)G#tdg`vGL`d7hP`zxWth?cE9F^f*#Os+A5Z0Z_m z0wsMa{aSidIwEb5>ZA#>0Q~t3;Qjn-!~`_qKCUQo?sf-AxNvVizT-aFXcuH~4j0cb zl#b}ljQ%hs{KZfTe|5vpcKED;PXIp4;8O*ka`?ESQ%uYny(OGmCXuboRv6z^zV#d* zvCJt|cB(@`H9tD{c|J;8_!0t4wC^ucHyUhyicgPDc#(exMbSd;y8YxH#75y>c`kOa zva&(!U!LO~(G^+DgM22br*i@FzJlvvzX>Pr2{tP{TazL0_mV_4HNnLP`i#;-Dt9-wS-HD2 z>v%#^mNkat!eu@kzhdR?%52~WNzGUiGtFD^u?+6MarJB_kW^(2Yr-qxFia>aI^D*V z(&Orx8xm~Kk_qS=5=65JK>@-rT~XG#erY`f?Lj*vopx7F0`A~*^8gKpUc24 zuX01?d@I4c3E0c8awpBX=_K2okiGfqoE0B@joWPz(?~gs?E4Jya?G!DA-ITIWWbgH z-18f52eNE5%QeeNsPaTI$4@TgVQsv{eF|!;aw&Qd^URu zy$7e_zv53F{IlNun=(Zxz98Oy#VzM9ae5t2_f&V)RHv)dxxdUoinL`N65^FK=JNN` zmXKYk%RXPcH)d7v*60fp`4uQ_c+LbFwjU^r^Q9H@m9!;Xo>^V7c~&IChN#Y)uj0fEP{X=UT$gV~~mhKrT6X)-+y=zDgP2*3Z+P;@{>X>@50y^i}3ZjFaxB) z-WWr z$>g48Kwecl)&n|+JOGF{XN=vSCt$a#&tlfWrJ+{1 z9=t0?KQHBIbQ4)J64gpE+vsPd>4tR%UiZ58k6Jxf#|i9W<_g+@+*B+0t`)dlRe8g|Q9*%UM> zvL?l@;jVSp$c>g)>WEK&haHDsUyL>}f2XO#xGZc6Pu7gGh+5D@ zeC;T`j1Cv!`N!zlC|B)aJwAI3tgPZn|3M3f=UHx)~HkKjRYN8@rY1-Igi^4O*Bm#;+sue=idwJ?R4xMHu9_P{qkeAbToo4h1#g^z5VdQ~coJ@5^~W2O&{2E!8l zN!`Eb^0XT?5A*NyrCgZ(0ZTD;v=2Q*{Tns^L~W)eoTJ?CK?p^ewb~{#map;SM1ClJ z$FA@+`Ig_1EulI;eitEhrxC5eWfO7|2s7}2#NR<+zlFe! zHA0OFZ~h}UiHOkPmpW@jfN8TfIQHsaNP;*JoS z8cF8P6>`-QfZK{2KjrQu@MMv|RW_SdZ719>)A0nJ-Y;_t1oqh|Z~!RXKrWd0r#vll z*BDd6!DsYvaz)eb~QrB9Hut8^)0-(NWe)~0|TB#hHE1W6p6 zYoBtD5X8+BBylt@T!Ru{G|+3{@f=r1X#586VfKBSo~PLQ}l~P@TBW*p;^!vpEmXyiwtiYP8#Ouuj|k0yYyzTeH}*P-i0Ou zR4Hb$h=Q@gg)IBql`b6dTgBa0Pt)d*+=~fbE5&JLE|L2%!D&T&R^#c=@vegnYUhLK zxpC3uF&TmGHaNWdf?WY$M+l*3UL^tE-ahZpEUIW%CbypXpPJU`V{-uZJtVEstN)OtA~KtO(-{&xri zS*~i2+g)Fu+{m~rh!lXTK&*kQ$^{>YH3+#X##YK(T2<|=sCL%RzJZ*|(Q;g=VirJ5 zxz|QyniUe46@s;n;emb>d~ zIVzMm8RGrq9wWzDm8v?G5U^2E-Z%@YGdIQ1m8Wc%Z$n^fTv#X}+N83)qS{?KbtXtF zNFmMT>+I7Fz;eE0W*9`{b+v|9S{v33dfj zE*Ouex}vhOayp1~#YK*ild38nPlc<}Sw0Q;iW2ysuoPv5Vv#nU@@jWQ<*FKB+7)MV zkJP_>)O{4{UoJOEy(h%4DJiSuEcZZDrY1E-776QLjkZX|SmCX%^iavDCJ(Ug695sj=U$~oxkQmK`iPe~=h1szSOk^K1rYY(45lUJuUKZ`b9^>~7*9|x8*XX{b{eyOa=B!5K{oMCCE4zjH z6;n==psm&5?8+-5+FKng300}R@+;nY=R5pPqW|T}M#S0#6x>QyydUv92%J+WD~<^; z*evCX!uXpALX<#N?CIC|4E#fyA0&*E3uHz?5L7LHKEt0P$oTxxj3UctVk67<%Zw8V z?6GCV_5b6n!kCjMi%cj{_S&^@$_*$xS7uCr!Eu*~Kjip6B6pmF!0rSXymsIQ<@o^u zPqPy^QAKy^0nXL%CkgDHK;T3@U54M#@baC;FFO`fNi)$&;JvFYsSenPf*c6BJc-#K0@H}Sp-fTh!Y0>b{WYWjr{IM;4H2) zDnm5hHW~(BvK7B+vi;La(vox%)T+==ZJM`$fI>bh`J-ZDBP;C1)J#bX1NigZVO|zFmgvAudb@yZS9riUjG*{ zP{Zo`tLEAD%N%)$^lMXvu*PmtBPA$pP2@|(28QFjSl#l9)`l1Hw*Dq%xI$mV>T`V& z1$B7u&hSEwt*MC~?vyQ$U&N7sH>8ZzrEW_$h9@wjq$lmep-ZeS(k^BQibXH5wYL*Y zu6T2p9>>7A;Q9ODMex}max-uVAa-+GWD}#ueLrPObdXa_6-cg*{;pS?qMuVkgTu?#UjrSp6P%!0&AL zD2qLbmfA;FduGiSGbgU}BoXo}L`{9TBJRGC9T&Hbh>Uq|lDMYq3x(aX=jLERg6AfQ zhWzMHzVsB!{tjbYcA}exUDw$a0PA%wIko83+iFHYzcxOXXi0bi&+ z(S2=*LldBp^1W}wrBF5UWU^=^3ISsKa*l%iwbC$6KU>$VU94HcujKq}kl9HegvU6i zz@@v{65eW(l7dO&(WK^HNkx=s(s($jxldFPCHO``AfD9RE2xMPPZ|#>H3y9*A25+9p zsZs{@V@VDjJQFcjs}$RGDn)Hv$eu$NEx^6!cfl!|qMIWnDxZ@S*xZ`2GITY{EE=DCO zjFT)sa*WMpY7qYDVcr&HO3`P?AzvlnB{%#{;x&kshJ-$<-*s?d{XnaoL3H6o#fow` zS^j5LWD~xkc$SaKpzfGKbbpmTFbej$MrBZkhReuJq_~(qlqR1gqX3AfAkuA2b|nk#9@zZzYUe zmO^7MO97%#C3?P#{tsRpLdw5o^S5peDYUho|7{lsM!h*KOHf!-D&=F9M*&; z^7=`XWE3s%uV54!^7NBnBBnzK!BB>j6pE{rV{y&2Rn1mnakX+VuDL&5J*q~IPbC`H m+?S?8szRWKQY3Ldq@3*_F)) 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 BrAhz* diff --git a/backend/tenants/security.py b/backend/tenants/security.py index c346d57..de4d16c 100644 --- a/backend/tenants/security.py +++ b/backend/tenants/security.py @@ -58,4 +58,5 @@ def process_response(self, request, response): response['X-XSS-Protection'] = '1; mode=block' response['Referrer-Policy'] = 'strict-origin-when-cross-origin' response['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' + response['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" return response diff --git a/backend/users/jwt.py b/backend/users/jwt.py index 4cb1bfc..c931914 100644 --- a/backend/users/jwt.py +++ b/backend/users/jwt.py @@ -1,4 +1,35 @@ +import jwt +from datetime import datetime, timedelta, timezone +from django.conf import settings +from rest_framework import serializers from rest_framework_simplejwt.serializers import TokenObtainPairSerializer class CustomTokenObtainSerializer(TokenObtainPairSerializer): username_field = 'email' + + def validate(self, attrs): + # We need to authenticate first. SimpleJWT validation handles active user check. + data = super().validate(attrs) + + # Enforce email verification + if not self.user.is_email_verified: + raise serializers.ValidationError('Email not verified. Please verify your email first.') + + # Enforce MFA/2FA check + if self.user.mfa_enabled: + temp_token = jwt.encode( + { + 'user_id': str(self.user.id), + 'mfa_pending': True, + 'exp': datetime.now(timezone.utc) + timedelta(minutes=5) + }, + settings.SECRET_KEY, + algorithm='HS256' + ) + return { + 'mfa_required': True, + 'mfa_token': temp_token, + 'email': self.user.email + } + + return data diff --git a/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py b/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py new file mode 100644 index 0000000..e2f9c89 --- /dev/null +++ b/backend/users/migrations/0003_user_is_email_verified_user_mfa_enabled_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.14 on 2026-07-12 06:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_member_role_default'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_email_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='mfa_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='mfa_secret', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 7bbe8a6..b9031fa 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -33,6 +33,9 @@ class User(AbstractBaseUser, PermissionsMixin, TenantModel): role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_MEMBER) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) + is_email_verified = models.BooleanField(default=False) + mfa_enabled = models.BooleanField(default=False) + mfa_secret = models.CharField(max_length=32, blank=True, null=True) objects = UserManager() diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 7ac9fbc..c90224c 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -25,6 +25,16 @@ def validate_email(self, value): raise serializers.ValidationError('A user with this email already exists.') return value + def validate(self, attrs): + from django.contrib.auth.password_validation import validate_password + from django.core.exceptions import ValidationError as DjangoValidationError + user = User(email=attrs.get('email')) + try: + validate_password(attrs.get('password'), user) + except DjangoValidationError as exc: + raise serializers.ValidationError({'password': list(exc.messages)}) + return attrs + def create(self, validated_data): org = Organization.objects.create(name=validated_data['organization_name']) user = User.objects.create_user( diff --git a/backend/users/tests.py b/backend/users/tests.py index 2c73fd9..59a5b8e 100644 --- a/backend/users/tests.py +++ b/backend/users/tests.py @@ -1,5 +1,9 @@ from rest_framework import status from rest_framework.test import APITestCase +from django.core import mail +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.contrib.auth.tokens import default_token_generator from tenants.models import Organization from users.models import User @@ -104,3 +108,152 @@ def test_member_cannot_delete_organization(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertTrue(Organization.objects.filter(id=self.organization.id).exists()) self.assertTrue(User.objects.filter(id=self.user.id).exists()) + + +class SecurityHardeningTests(APITestCase): + def setUp(self): + self.organization = Organization.objects.create(name='Security Org') + + def test_password_complexity_validator(self): + # Register with simple password (missing special char and digit) + response = self.client.post( + '/api/v1/auth/register/', + { + 'email': 'test_weak@example.com', + 'password': 'password', + 'organization_name': 'New Org', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('password', response.data) + + def test_email_verification_flow(self): + # 1. Register user + response = self.client.post( + '/api/v1/auth/register/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + 'organization_name': 'New Org', + }, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('user', response.data) + user = User.objects.get(email='unverified@example.com') + self.assertFalse(user.is_email_verified) + + # Ensure verification email was sent to outbox + self.assertEqual(len(mail.outbox), 1) + self.assertIn('Verify Your LeadOrbit Email', mail.outbox[0].subject) + + # 2. Try logging in (should fail since email is unverified) + login_response = self.client.post( + '/api/v1/token/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', login_response.data) + + # 3. Get verification token/uid + uidb64 = urlsafe_base64_encode(force_bytes(user.pk)) + token = default_token_generator.make_token(user) + + # 4. Verify email + verify_response = self.client.post( + '/api/v1/auth/verify-email/', + { + 'uid': uidb64, + 'token': token, + }, + format='json', + ) + self.assertEqual(verify_response.status_code, status.HTTP_200_OK) + user.refresh_from_db() + self.assertTrue(user.is_email_verified) + + # 5. Log in after verification (should succeed) + login_response2 = self.client.post( + '/api/v1/token/', + { + 'email': 'unverified@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response2.status_code, status.HTTP_200_OK) + self.assertIn('access', login_response2.data) + + def test_mfa_flow(self): + import pyotp + # Create a verified user + user = User.objects.create_user( + email='mfa_user@example.com', + password='StrongPass123!', + organization=self.organization, + role=User.ROLE_ADMIN, + ) + user.is_email_verified = True + user.save() + + # 1. Enable MFA setup + self.client.force_authenticate(user) + enable_response = self.client.post('/api/v1/auth/enable-mfa/', format='json') + self.assertEqual(enable_response.status_code, status.HTTP_200_OK) + self.assertIn('secret', enable_response.data) + secret = enable_response.data['secret'] + + # 2. Confirm MFA setup with valid TOTP code + totp = pyotp.TOTP(secret) + confirm_response = self.client.post( + '/api/v1/auth/confirm-mfa/', + {'code': totp.now()}, + format='json', + ) + self.assertEqual(confirm_response.status_code, status.HTTP_200_OK) + user.refresh_from_db() + self.assertTrue(user.mfa_enabled) + + # 3. Log in (should return MFA challenge/token) + self.client.logout() + login_response = self.client.post( + '/api/v1/token/', + { + 'email': 'mfa_user@example.com', + 'password': 'StrongPass123!', + }, + format='json', + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + self.assertTrue(login_response.data.get('mfa_required')) + mfa_token = login_response.data.get('mfa_token') + self.assertIsNotNone(mfa_token) + + # 4. Verify MFA with invalid code (should fail) + verify_response_fail = self.client.post( + '/api/v1/auth/verify-mfa/', + { + 'mfa_token': mfa_token, + 'code': '000000', + }, + format='json', + ) + self.assertEqual(verify_response_fail.status_code, status.HTTP_400_BAD_REQUEST) + + # 5. Verify MFA with valid code (should succeed and return JWT tokens) + verify_response_success = self.client.post( + '/api/v1/auth/verify-mfa/', + { + 'mfa_token': mfa_token, + 'code': totp.now(), + }, + format='json', + ) + self.assertEqual(verify_response_success.status_code, status.HTTP_200_OK) + self.assertIn('access', verify_response_success.data) + diff --git a/backend/users/throttling.py b/backend/users/throttling.py new file mode 100644 index 0000000..e70be67 --- /dev/null +++ b/backend/users/throttling.py @@ -0,0 +1,24 @@ +from rest_framework.throttling import SimpleRateThrottle + +class LoginRateThrottle(SimpleRateThrottle): + scope = 'login' + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + ident = request.user.pk + else: + ident = self.get_ident(request) + + username = None + if hasattr(request, 'data') and isinstance(request.data, dict): + username = request.data.get('email') + + if username: + return self.cache_format % { + 'scope': self.scope, + 'ident': f"{ident}_{username}" + } + return self.cache_format % { + 'scope': self.scope, + 'ident': ident + } diff --git a/backend/users/validators.py b/backend/users/validators.py new file mode 100644 index 0000000..025fec7 --- /dev/null +++ b/backend/users/validators.py @@ -0,0 +1,32 @@ +import re +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +class ComplexityPasswordValidator: + def validate(self, password, user=None): + if not re.search(r'[A-Z]', password): + raise ValidationError( + _("The password must contain at least one uppercase letter."), + code='password_no_upper', + ) + if not re.search(r'[a-z]', password): + raise ValidationError( + _("The password must contain at least one lowercase letter."), + code='password_no_lower', + ) + if not re.search(r'\d', password): + raise ValidationError( + _("The password must contain at least one digit."), + code='password_no_digit', + ) + if not re.search(r'[!@#$%^&*(),.?":{}|<>\-_=+\[\];:\']', password): + raise ValidationError( + _("The password must contain at least one special character."), + code='password_no_special', + ) + + def get_help_text(self): + return _( + "Your password must contain at least one uppercase letter, " + "one lowercase letter, one digit, and one special character." + ) diff --git a/backend/users/views.py b/backend/users/views.py index 71d5c1b..0583284 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -9,20 +9,124 @@ from .serializers import UserSerializer, RegisterSerializer from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import urlsafe_base64_encode +from django.utils.encoding import force_bytes +from django.core.mail import send_mail +from django.conf import settings +from users.throttling import LoginRateThrottle +import pyotp +import jwt + class AuthViewSet(viewsets.GenericViewSet): - @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + @action(detail=False, methods=['post'], permission_classes=[AllowAny], throttle_classes=[LoginRateThrottle]) def register(self, request): serializer = RegisterSerializer(data=request.data) if serializer.is_valid(): user = serializer.save() - refresh = RefreshToken.for_user(user) + + # Generate email verification token and UID + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + + # Build verification link + frontend_url = getattr(settings, 'FRONTEND_BASE_URL', '') or 'http://localhost:3000' + verification_link = f"{frontend_url}/verify-email?uid={uid}&token={token}" + + # Send verification email + send_mail( + 'Verify Your LeadOrbit Email', + f'Please verify your email by clicking the following link: {verification_link}', + 'noreply@leadorbit.com', + [user.email], + fail_silently=False, + ) + return Response({ 'user': UserSerializer(user).data, - 'refresh': str(refresh), - 'access': str(refresh.access_token), + 'message': 'Registration successful. Please check your email to verify your account.' }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @action(detail=False, methods=['post'], permission_classes=[AllowAny], url_path='verify-email', throttle_classes=[LoginRateThrottle]) + def verify_email(self, request): + uidb64 = request.data.get('uid') + token = request.data.get('token') + if not uidb64 or not token: + return Response({'error': 'Missing uid or token.'}, status=status.HTTP_400_BAD_REQUEST) + + try: + from django.utils.http import urlsafe_base64_decode + from django.utils.encoding import force_str + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + + if user is not None and default_token_generator.check_token(user, token): + user.is_email_verified = True + user.save(update_fields=['is_email_verified']) + return Response({'message': 'Email successfully verified.'}, status=status.HTTP_200_OK) + return Response({'error': 'Invalid or expired verification token.'}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated], url_path='enable-mfa') + def enable_mfa(self, request): + user = request.user + if user.mfa_enabled: + return Response({'detail': 'MFA is already enabled.'}, status=status.HTTP_400_BAD_REQUEST) + + secret = pyotp.random_base32() + user.mfa_secret = secret + user.save(update_fields=['mfa_secret']) + + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri(name=user.email, issuer_name="LeadOrbit") + return Response({ + 'secret': secret, + 'provisioning_uri': provisioning_uri + }, status=status.HTTP_200_OK) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated], url_path='confirm-mfa') + def confirm_mfa(self, request): + user = request.user + code = request.data.get('code') + if not code: + return Response({'error': 'Verification code is required.'}, status=status.HTTP_400_BAD_REQUEST) + if not user.mfa_secret: + return Response({'error': 'MFA setup has not been initiated.'}, status=status.HTTP_400_BAD_REQUEST) + + totp = pyotp.TOTP(user.mfa_secret) + if totp.verify(code): + user.mfa_enabled = True + user.save(update_fields=['mfa_enabled']) + return Response({'message': 'MFA enabled successfully.'}, status=status.HTTP_200_OK) + return Response({'error': 'Invalid verification code.'}, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=False, methods=['post'], permission_classes=[AllowAny], url_path='verify-mfa', throttle_classes=[LoginRateThrottle]) + def verify_mfa(self, request): + mfa_token = request.data.get('mfa_token') + code = request.data.get('code') + if not mfa_token or not code: + return Response({'error': 'mfa_token and verification code are required.'}, status=status.HTTP_400_BAD_REQUEST) + + try: + payload = jwt.decode(mfa_token, settings.SECRET_KEY, algorithms=['HS256']) + if not payload.get('mfa_pending'): + raise jwt.InvalidTokenError() + user_id = payload.get('user_id') + user = User.objects.get(pk=user_id) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist): + return Response({'error': 'Invalid or expired MFA token.'}, status=status.HTTP_400_BAD_REQUEST) + + totp = pyotp.TOTP(user.mfa_secret) + if totp.verify(code): + refresh = RefreshToken.for_user(user) + return Response({ + 'refresh': str(refresh), + 'access': str(refresh.access_token), + }, status=status.HTTP_200_OK) + return Response({'error': 'Invalid verification code.'}, status=status.HTTP_400_BAD_REQUEST) + @action(detail=False, methods=['get', 'patch'], permission_classes=[IsAuthenticated]) def me(self, request): if request.method == 'PATCH': diff --git a/requirements.txt b/requirements.txt index 1598dc3..c213f8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ cryptography>=48.0.1 firebase-admin>=6.5,<7.0 twilio>=9.0,<10.0 gunicorn>=20.1,<21.0 +pyotp>=2.9.0