Skip to content

Commit da5ddb3

Browse files
authored
Merge pull request #37 from 1toldyou/main
alternative login method
2 parents 01cf300 + e6cdffa commit da5ddb3

File tree

13 files changed

+410
-84
lines changed

13 files changed

+410
-84
lines changed

Document/FeatureSpec.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ which including interactive interface with minial amount of explanation about ho
6464
After period of development, while we're getting more functionality, the code are getting messier, so it's time to rewrite some of these code
6565
- Break code into smaller file by utilize the Router API
6666
- New URL scheme that describe the action of this endpoint at the end;
67-
combined public and private endpoint, authenticate based on the token passed-in
67+
combined public and private endpoint, authenticate based on the token passed-in;
68+
if the request modify any existing data then its POST request, GET is read-only
6869
- Performance is the priority now;
6970
using direct native connection to the database achieved resulted unnoticeable API latency
7071
optimized query to reduce overhead
@@ -76,6 +77,10 @@ Might not a significant feature
7677

7778
### Permanently Death
7879
#### SQL Database
80+
Somewhat complicated to make SQL query for the data-structure I already made but seamlessly works with Document-Base NotOnlySQL database,
81+
plus can immune common SQL-Injection attack
7982

80-
83+
## Developer's notebook
84+
### Authentication
85+
#### GitHub OAuth
8186

Document/WorkBreakdownStructure.md

Lines changed: 27 additions & 26 deletions
Large diffs are not rendered by default.

example_data.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,22 @@
150150
"expiration_timestamp_int": 1658293369
151151
}
152152

153-
PASSWORD_INFO = {
153+
LOGIN_INFO_1 = {
154154
"structure_version": 1,
155155
"person_id": "1234567890",
156-
"password_hash": "12B03226A6D8BE9C6E8CD5E55DC6C7920CAAA39DF14AAB92D5E3EA9340D1C8A4D3D0B8E4314F1F6EF131BA4BF1CEB9186AB87C801AF0D5C95B1BEFB8CEDAE2B9",
157-
"password_length": 8,
156+
"password_hash": "12b03226a6d8be9c6e8cd5e55dc6c7920caaa39df14aab92d5e3ea9340d1c8a4d3d0b8e4314f1f6ef131ba4bf1ceb9186ab87c801af0d5c95b1befb8cedae2b9",
157+
"password_length": 10,
158+
}
159+
160+
LOGIN_INFO_2 = {
161+
"structure_version": 2,
162+
"person_id": "1234567890",
163+
"password_hash": "12b03226a6d8be9c6e8cd5e55dc6c7920caaa39df14aab92d5e3ea9340d1c8a4d3d0b8e4314f1f6ef131ba4bf1ceb9186ab87c801af0d5c95b1befb8cedae2b9", # the generated from hashlib is in lowercase
164+
"password_length": 10,
165+
"totp_status": "enabled",
166+
"totp_secret_key": "MWKXM4SZS7O2Q7S5KU5TBJ2INYSH42UQ", # pyotp.random_base32()
167+
"github_oauth_status": "enabled",
168+
"github_email": "example@752628.xyz"
158169
}
159170

160171
# For directly compatible with vanilla JSON, do not add comma after each last item

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@ slowapi
66
starlette
77
python-multipart
88
captcha
9-
pymongo
9+
pymongo
10+
dnspython
11+
pyotp
12+
aiohttp

route/v2_auth.py

Lines changed: 170 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
# Builtin library
2-
from datetime import datetime
32
import hashlib
3+
import aiohttp
44

55
# Framework core library
66
from starlette.requests import Request
77
from fastapi import APIRouter, Header
88
from fastapi.responses import JSONResponse
9+
import pyotp
910

1011
# Local file
11-
from util import random_content, json_body
12+
from util import json_body, token_tool
1213
import util.pymongo_wrapper as DocumentDB
1314

1415
router = APIRouter()
1516

1617

17-
@router.post("/token/generate", tags=["V2"])
18-
async def v2_generate_auth_token(request: Request, cred: json_body.PasswordLoginBody):
18+
@router.post("/token/revoke", tags=["V2"])
19+
async def v2_revoke_auth_token(request: Request, pa_token: str = Header(None)):
20+
mongo_client = DocumentDB.get_client()
21+
db_client = mongo_client.get_database(DocumentDB.DB)
22+
token_deletion_query = DocumentDB.delete_one(
23+
collection="TokenV3",
24+
find_filter={"token_value": pa_token},
25+
db_client=db_client)
26+
mongo_client.close()
27+
if token_deletion_query is None:
28+
return JSONResponse(status_code=500, content={"status": "failed to remove the old token to database", "pa_token": pa_token})
29+
elif token_deletion_query.deleted_count == 0:
30+
return JSONResponse(status_code=404, content={"status": "token not found", "pa_token": pa_token})
31+
elif token_deletion_query.deleted_count == 1:
32+
return JSONResponse(status_code=200, content={"status": "deleted", "pa_token": pa_token})
33+
34+
35+
@router.post("/password/verify", tags=["V2"])
36+
async def v2_verify_auth_password(request: Request, cred: json_body.PasswordLoginBody):
1937
mongo_client = DocumentDB.get_client()
2038
db_client = mongo_client.get_database(DocumentDB.DB)
2139
credential_verify_query = DocumentDB.find_one(collection="LoginV1",
@@ -30,51 +48,12 @@ async def v2_generate_auth_token(request: Request, cred: json_body.PasswordLogin
3048
content={"status": "not found or not match",
3149
"person_id": cred.person_id,
3250
"password": cred.password})
33-
while True:
34-
# Checking if the same token already being use
35-
# There is no do-while loop in Python
36-
generated_token = random_content.generate_access_token()
37-
current_checking_query = DocumentDB.find_one(collection="TokenV1",
38-
find_filter={"token_value": generated_token},
39-
db_client=db_client)
40-
if current_checking_query is None:
41-
break
42-
create_at = int(datetime.now().timestamp())
43-
expire_at = create_at + cred.token_lifespan
44-
token_record_query = DocumentDB.insert_one(
45-
collection="TokenV3",
46-
document_body={
47-
"structure_version": 3,
48-
"person_id": cred.person_id,
49-
"token_value": generated_token,
50-
"token_hash": hashlib.sha512(generated_token.encode("utf-8")).hexdigest(),
51-
"creation_timestamp_int": create_at,
52-
"expiration_timestamp_int": expire_at
53-
},
54-
db_client=db_client)
55-
if token_record_query is None:
56-
return JSONResponse(status_code=500,
57-
content={"status": "token generated but failed to insert that token to database"})
51+
generated_token = token_tool.generate_pa_token_and_record(db_client=db_client,
52+
person_id=cred.person_id,
53+
token_lifespan=cred.token_lifespan)
5854
mongo_client.close()
5955
return JSONResponse(status_code=200,
60-
content={"status": "success", "pa_token": generated_token, "expiration_timestamp": expire_at})
61-
62-
63-
@router.post("/token/revoke", tags=["V2"])
64-
async def v2_revoke_auth_token(request: Request, pa_token: str = Header(None)):
65-
mongo_client = DocumentDB.get_client()
66-
db_client = mongo_client.get_database(DocumentDB.DB)
67-
token_deletion_query = DocumentDB.delete_one(
68-
collection="TokenV3",
69-
find_filter={"token_value": pa_token},
70-
db_client=db_client)
71-
mongo_client.close()
72-
if token_deletion_query is None:
73-
return JSONResponse(status_code=500, content={"status": "failed to remove the old token to database", "pa_token": pa_token})
74-
elif token_deletion_query.deleted_count == 0:
75-
return JSONResponse(status_code=404, content={"status": "token not found", "pa_token": pa_token})
76-
elif token_deletion_query.deleted_count == 1:
77-
return JSONResponse(status_code=200, content={"status": "deleted", "pa_token": pa_token})
56+
content={"status": "success", "pa_token": generated_token[0], "expiration_timestamp": generated_token[1]})
7857

7958

8059
# TODO: revoke existing session/token
@@ -112,3 +91,147 @@ async def v2_update_auth_password(request: Request, old_cred: json_body.Password
11291
"password": old_cred.password})
11392
mongo_client.close()
11493
return JSONResponse(status_code=200, content={"status": "success", "voided": old_cred.password})
94+
95+
96+
@router.post("/totp/enable", tags=["V2"])
97+
async def v2_enable_auth_totp(request: Request, cred: json_body.PasswordLoginBody):
98+
mongo_client = DocumentDB.get_client()
99+
db_client = mongo_client.get_database(DocumentDB.DB)
100+
# same as the traditional plain-password login
101+
credential_verify_query = DocumentDB.find_one(collection="LoginV2",
102+
find_filter={"person_id": cred.person_id},
103+
db_client=db_client)
104+
print(credential_verify_query)
105+
if (credential_verify_query is None) \
106+
or (hashlib.sha512(cred.password.encode("utf-8")).hexdigest() != credential_verify_query["password_hash"]):
107+
mongo_client.close()
108+
return JSONResponse(status_code=403,
109+
content={"status": "user not found or not match",
110+
"person_id": cred.person_id,
111+
"password": cred.password})
112+
if credential_verify_query["totp_status"] != "disabled":
113+
mongo_client.close()
114+
return JSONResponse(status_code=200,
115+
content={"status": "Time-based OTP already enabled for this user",
116+
"person_id": cred.person_id})
117+
new_secret_key = pyotp.random_base32()
118+
authenticator_url = pyotp.totp.TOTP(new_secret_key).provisioning_uri(name=cred.person_id,
119+
issuer_name='Plan-At')
120+
credential_modify_query = DocumentDB.update_one(db_client=db_client,
121+
collection="LoginV2",
122+
find_filter={"person_id": cred.person_id},
123+
changes={"$set": {"totp_status": "enabled",
124+
"totp_secret_key": new_secret_key}})
125+
if credential_modify_query.matched_count != 1 and credential_modify_query.modified_count != 1:
126+
return JSONResponse(status_code=500, content={"status": "failed to register the secret_key for totp in database",
127+
"matched_count": credential_modify_query.matched_count,
128+
"modified_count": credential_modify_query.modified_count})
129+
mongo_client.close()
130+
return JSONResponse(status_code=200,
131+
content={"status": "Time-based OTP enabled for this user",
132+
"person_id": cred.person_id,
133+
"authenticator_url": authenticator_url})
134+
135+
136+
@router.post("/totp/disable", tags=["V2"])
137+
async def v2_disable_auth_totp(request: Request, cred: json_body.PasswordLoginBody):
138+
# Copy and Paste of /enable
139+
# Not require current output from Authenticator since the user might lose their
140+
mongo_client = DocumentDB.get_client()
141+
db_client = mongo_client.get_database(DocumentDB.DB)
142+
# same as the traditional plain-password login
143+
credential_verify_query = DocumentDB.find_one(collection="LoginV2",
144+
find_filter={"person_id": cred.person_id},
145+
db_client=db_client)
146+
print(credential_verify_query)
147+
if (credential_verify_query is None) \
148+
or (hashlib.sha512(cred.password.encode("utf-8")).hexdigest() != credential_verify_query["password_hash"]):
149+
mongo_client.close()
150+
return JSONResponse(status_code=403,
151+
content={"status": "user not found or not match",
152+
"person_id": cred.person_id,
153+
"password": cred.password})
154+
# checking current totp status
155+
if credential_verify_query["totp_status"] != "enabled":
156+
mongo_client.close()
157+
return JSONResponse(status_code=200,
158+
content={"status": "Time-based OTP not enabled for this user",
159+
"person_id": cred.person_id})
160+
credential_modify_query = DocumentDB.update_one(db_client=db_client,
161+
collection="LoginV2",
162+
find_filter={"person_id": cred.person_id},
163+
changes={"$set": {"totp_status": "disabled",
164+
"totp_secret_key": ""}})
165+
if credential_modify_query.matched_count != 1 and credential_modify_query.modified_count != 1:
166+
return JSONResponse(status_code=500, content={"status": "failed to delete existing secret_key for totp in database",
167+
"matched_count": credential_modify_query.matched_count,
168+
"modified_count": credential_modify_query.modified_count})
169+
mongo_client.close()
170+
return JSONResponse(status_code=200,
171+
content={"status": "Time-based OTP disabled for this user",
172+
"person_id": cred.person_id})
173+
174+
175+
@router.post("/totp/verify", tags=["V2"])
176+
async def v2_verify_auth_totp(request: Request, person_id: str, totp_code: str):
177+
# Copy and Paste of /disable
178+
# Not require current output from Authenticator since the user might lose their
179+
mongo_client = DocumentDB.get_client()
180+
db_client = mongo_client.get_database(DocumentDB.DB)
181+
# if len(str(int(totp_code))) != 6: # also verify if its actual int but not working if start with zero
182+
if len(totp_code) != 6: # also verify if its actual int but not working if start with zero
183+
return JSONResponse(status_code=400,
184+
content={"status": "totp_code malformed",
185+
"totp_code": totp_code})
186+
# same as the traditional plain-password login
187+
credential_verify_query = DocumentDB.find_one(collection="LoginV2",
188+
find_filter={"person_id": person_id},
189+
db_client=db_client)
190+
print(credential_verify_query)
191+
if credential_verify_query is None:
192+
mongo_client.close()
193+
return JSONResponse(status_code=403,
194+
content={"status": "user not found or totp_code not match",
195+
"person_id": person_id,
196+
"totp_code": totp_code})
197+
# Checking current totp status
198+
if credential_verify_query["totp_status"] != "enabled":
199+
mongo_client.close()
200+
return JSONResponse(status_code=200,
201+
content={"status": "Time-based OTP not enabled for this user",
202+
"person_id": person_id})
203+
204+
if not pyotp.TOTP(credential_verify_query["totp_secret_key"]).verify(totp_code):
205+
mongo_client.close()
206+
return JSONResponse(status_code=403,
207+
content={"status": "user not found or totp_code not match",
208+
"person_id": person_id,
209+
"totp_code": totp_code})
210+
generated_token = token_tool.generate_pa_token_and_record(db_client=db_client,
211+
person_id=person_id,
212+
token_lifespan=(60 * 60 * 24 * 1))
213+
mongo_client.close()
214+
return JSONResponse(status_code=200,
215+
content={"status": "success",
216+
"person_id": person_id,
217+
"pa_token": generated_token[0],
218+
"expiration_timestamp": generated_token[1]})
219+
220+
221+
@router.post("/github/enable", tags=["V2"])
222+
async def v2_enable_auth_github(request: Request, req_body: json_body.GitHubOAuthCode, pa_token: str = Header(None)):
223+
github_session = aiohttp.ClientSession()
224+
a = await github_session.post(f"https://github.com/login/oauth/access_token?client_id={1}&client_secret={2}&code={3}")
225+
print(a.status, a.text())
226+
a = a.json()
227+
return JSONResponse(status_code=200, content={"status": "success", "code": req_body.code})
228+
229+
230+
@router.post("/github/disable", tags=["V2"])
231+
async def v2_disable_auth_github(request: Request, req_body: json_body.GitHubOAuthCode, pa_token: str = Header(None)):
232+
pass
233+
234+
235+
@router.post("/github/verify", tags=["V2"])
236+
async def v2_verify_auth_github(request: Request, req_body: json_body.GitHubOAuthCode):
237+
pass

route/v2_user.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,16 @@ async def v2_create_user(request: Request, user_profile: json_body.UserProfileOb
100100
document_body={"structure_version": 1, "person_id": person_id, "event_id_list": []})
101101
print(calendar_index_insert_query.inserted_id)
102102
login_credential_insert_query = DocumentDB.insert_one(db_client=db_client,
103-
collection="LoginV1",
103+
collection="LoginV2",
104104
document_body={
105-
"structure_version": 1,
105+
"structure_version": 2,
106106
"person_id": person_id,
107107
"password_hash": hashlib.sha512(password.encode("utf-8")).hexdigest(),
108108
"password_length": len(password),
109+
"totp_status": "disabled",
110+
"totp_secret_key": "",
111+
"github_oauth_status": "disabled",
112+
"github_email": ""
109113
})
110114
print(login_credential_insert_query.inserted_id)
111115
mongo_client.close()

server.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
from starlette.requests import Request
99
from starlette.responses import Response, RedirectResponse
1010
from fastapi import FastAPI
11-
from fastapi.responses import JSONResponse
11+
from fastapi.responses import JSONResponse, HTMLResponse
1212
from fastapi.openapi.utils import get_openapi
1313
from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
14+
from fastapi.middleware.cors import CORSMiddleware
1415
from slowapi import Limiter, _rate_limit_exceeded_handler
1516
from slowapi.errors import RateLimitExceeded
1617
from slowapi.util import get_remote_address
1718

1819
from constant import ServerConfig, RateLimitConfig, MediaAssets, START_TIME, PROGRAM_HASH
1920
from route import fake, v1, v2, v2_captcha, v2_auth, v2_user, v2_calendar, v2_hosting
21+
from util import docs_page
2022

2123
app = FastAPI()
2224

@@ -32,6 +34,15 @@
3234
app.include_router(fake.router, prefix="/fake")
3335
app.include_router(v1.router)
3436

37+
"""enable this for local development or where have no nginx presence"""
38+
# app.add_middleware(
39+
# CORSMiddleware,
40+
# allow_origins="*",
41+
# allow_credentials=True,
42+
# allow_methods=["*"],
43+
# allow_headers=["*"],
44+
# )
45+
3546

3647
@app.middleware("http")
3748
async def log_requests(request: Request, call_next):
@@ -89,6 +100,11 @@ def get_favicon(request: Request):
89100
return RedirectResponse(url=MediaAssets.FAVICON)
90101

91102

103+
@app.get("/doc", include_in_schema=False)
104+
def overridden_swagger():
105+
return HTMLResponse(status_code=200, content=docs_page.HTML)
106+
107+
92108
@app.get("/docs", include_in_schema=False)
93109
def overridden_swagger():
94110
return get_swagger_ui_html(openapi_url="/openapi.json", title="Plan-At", swagger_favicon_url=MediaAssets.FAVICON)
@@ -156,6 +172,20 @@ def api_tool_delay(request: Request, sleep_time: int):
156172
return JSONResponse(status_code=200, content={"status": "finished"})
157173

158174

175+
@app.get("/everything")
176+
async def receive_everything(request: Request):
177+
print(request.headers)
178+
print(await request.body())
179+
return JSONResponse(status_code=200, content={"status": "finished"})
180+
181+
182+
@app.post("/everything")
183+
async def receive_everything(request: Request):
184+
print(request.headers)
185+
print(await request.body())
186+
return JSONResponse(status_code=200, content={"status": "finished"})
187+
188+
159189
if __name__ == "__main__":
160190
if sys.platform == "win32":
161191
uvicorn.run("server:app", debug=True, reload=True, port=ServerConfig.PORT, host=ServerConfig.HOST,

0 commit comments

Comments
 (0)