Skip to content

Commit 46d53c1

Browse files
committed
feat: Implement OAuth integration with Google and enhance user dashboard
- Added OAuth functionality for Google login, including state management and user info extraction. - Created new utility functions for handling OAuth user creation and linking. - Updated user model to store OAuth provider information. - Enhanced the dashboard settings page to display connected accounts and allow linking of Google accounts. - Introduced a modal for setting passwords and improved user authentication flow. - Added CSS styles for new UI components related to OAuth and user settings. - Updated MongoDB indexes to support OAuth provider queries.
1 parent e70d5f7 commit 46d53c1

11 files changed

Lines changed: 1501 additions & 102 deletions

File tree

.env.example

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,18 @@ JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n.....\n-----END PUBLIC KEY-----"
3030
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt_private_key.pem
3131

3232
# for public key:
33-
# openssl rsa -pubout -in jwt_private_key.pem -out jwt_public_key.pem
33+
# openssl rsa -pubout -in jwt_private_key.pem -out jwt_public_key.pem
34+
35+
# OAuth configs
36+
# How to generate these keys:
37+
# 1. Go to https://console.cloud.google.com/apis/credentials
38+
# 2. Create a new project (if you don't have one)
39+
# 3. Enable the Google+ API
40+
# 4. Configure OAuth consent screen (make sure to add http://localhost:8000 and http://127.0.0.1:8000)
41+
# 5. Create OAuth 2.0 Client IDs and get the client ID and client secret
42+
# 6. Set the authorized redirect URIs to:
43+
# http://localhost:8000/oauth/google/callback (for local dev)
44+
# http://127.0.0.1:8000/oauth/google/callback (for local dev)
45+
# http://your-production-domain.com/oauth/google/callback (for production)
46+
GOOGLE_OAUTH_CLIENT_ID=""
47+
GOOGLE_OAUTH_CLIENT_SECRET=""

blueprints/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Blueprint imports for the URL shortener application
2+
3+
# Core authentication (email/password)
4+
from .auth import auth
5+
6+
# OAuth authentication (Google, etc.)
7+
from .oauth import oauth_bp, init_oauth_for_app
8+
9+
# Dashboard routes (settings, links, keys, statistics)
10+
from .dashboard import dashboard_bp
11+
12+
# Other blueprints
13+
from .api import api
14+
from .contact import contact
15+
from .docs import docs
16+
from .limiter import limiter
17+
from .seo import seo
18+
from .stats import stats
19+
from .url_shortener import url_shortener
20+
from .redirector import url_redirector
21+
22+
__all__ = [
23+
"auth",
24+
"oauth_bp",
25+
"init_oauth_for_app",
26+
"dashboard_bp",
27+
"api",
28+
"contact",
29+
"docs",
30+
"limiter",
31+
"seo",
32+
"stats",
33+
"url_shortener",
34+
"url_redirector",
35+
]

blueprints/auth.py

Lines changed: 48 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import datetime, timezone
22

3-
from flask import Blueprint, jsonify, request, g, render_template, redirect
3+
from flask import Blueprint, jsonify, request, g
44

55
from .limiter import limiter
66
from utils.auth_utils import (
@@ -21,21 +21,13 @@
2121
users_collection,
2222
)
2323
from utils.url_utils import get_client_ip
24+
from utils.auth_utils import get_user_profile
2425
import jwt
25-
26+
from bson import ObjectId
2627

2728
auth = Blueprint("auth", __name__)
2829

2930

30-
def _minimal_user_profile(user_doc):
31-
return {
32-
"id": str(user_doc["_id"]),
33-
"email": user_doc.get("email"),
34-
"user_name": user_doc.get("user_name"),
35-
"plan": user_doc.get("plan", "free"),
36-
}
37-
38-
3931
@auth.route("/auth/login", methods=["POST"])
4032
@limiter.limit("5/minute")
4133
@limiter.limit("50/day")
@@ -47,16 +39,16 @@ def login():
4739
return jsonify({"error": "email and password are required"}), 400
4840

4941
user = get_user_by_email(email)
50-
if not user or not user.get("password"):
42+
if not user or not user.get("password_hash"):
5143
# Do not reveal which part failed
5244
return jsonify({"error": "invalid credentials"}), 401
5345

54-
if not verify_password(password, user["password"]):
46+
if not verify_password(password, user["password_hash"]):
5547
return jsonify({"error": "invalid credentials"}), 401
5648

5749
access_token = generate_access_jwt(str(user["_id"]))
5850
refresh_token = generate_refresh_jwt(str(user["_id"]))
59-
resp = jsonify({"access_token": access_token, "user": _minimal_user_profile(user)})
51+
resp = jsonify({"access_token": access_token, "user": get_user_profile(user)})
6052
set_refresh_cookie(resp, refresh_token)
6153
set_access_cookie(resp, access_token)
6254
return resp, 200
@@ -105,7 +97,7 @@ def me():
10597
user = get_user_by_id(user_id)
10698
if not user:
10799
return jsonify({"error": "user not found"}), 404
108-
return jsonify({"user": _minimal_user_profile(user)})
100+
return jsonify({"user": get_user_profile(user)})
109101

110102

111103
@auth.route("/auth/register", methods=["POST"])
@@ -129,12 +121,17 @@ def register():
129121
password_hash = hash_password(password)
130122
user_doc = {
131123
"email": email,
132-
"password": password_hash,
124+
"email_verified": False, # Email not verified initially
125+
"password_hash": password_hash,
126+
"password_set": True,
133127
"user_name": user_name,
128+
"pfp": None,
129+
"auth_providers": [],
134130
"plan": "free",
135131
"signup_ip": get_client_ip(),
136132
"created_at": datetime.now(timezone.utc),
137133
"updated_at": datetime.now(timezone.utc),
134+
"status": "ACTIVE",
138135
}
139136
try:
140137
insert_result = users_collection.insert_one(user_doc)
@@ -148,68 +145,54 @@ def register():
148145
resp = jsonify(
149146
{
150147
"access_token": access_token,
151-
"user": _minimal_user_profile({"_id": user_id, **user_doc}),
148+
"user": get_user_profile({"_id": user_id, **user_doc}),
152149
}
153150
)
154151
set_refresh_cookie(resp, refresh_token)
155152
set_access_cookie(resp, access_token)
156153
return resp, 201
157154

158155

159-
@auth.route("/dashboard", methods=["GET"])
156+
@auth.route("/auth/set-password", methods=["POST"])
160157
@requires_auth
161-
def dashboard():
162-
# Redirect to links page as the default dashboard view
163-
return redirect("/dashboard/links")
164-
165-
166-
@auth.route("/dashboard/links", methods=["GET"])
167-
@requires_auth
168-
def dashboard_links():
158+
@limiter.limit("5/minute")
159+
def set_password():
160+
"""Set password for OAuth-only users"""
169161
user = get_user_by_id(g.user_id)
170162
if not user:
171163
return jsonify({"error": "user not found"}), 404
172-
return render_template(
173-
"dashboard/links.html",
174-
host_url=request.host_url,
175-
user=_minimal_user_profile(user),
176-
)
177-
178164

179-
@auth.route("/dashboard/keys", methods=["GET"])
180-
@requires_auth
181-
def dashboard_keys():
182-
user = get_user_by_id(g.user_id)
183-
if not user:
184-
return jsonify({"error": "user not found"}), 404
185-
return render_template(
186-
"dashboard/keys.html",
187-
host_url=request.host_url,
188-
user=_minimal_user_profile(user),
189-
)
165+
if user.get("password_set", False):
166+
return jsonify({"error": "password already set"}), 400
190167

168+
body = request.get_json(silent=True) or {}
169+
password = body.get("password") or ""
191170

192-
@auth.route("/dashboard/statistics", methods=["GET"])
193-
@requires_auth
194-
def dashboard_statistics():
195-
user = get_user_by_id(g.user_id)
196-
if not user:
197-
return jsonify({"error": "user not found"}), 404
198-
return render_template(
199-
"dashboard/statistics.html",
200-
host_url=request.host_url,
201-
user=_minimal_user_profile(user),
202-
)
171+
if not password:
172+
return jsonify({"error": "password is required"}), 400
203173

174+
if len(password) < 8:
175+
return jsonify({"error": "password must be at least 8 characters"}), 400
204176

205-
@auth.route("/dashboard/settings", methods=["GET"])
206-
@requires_auth
207-
def dashboard_settings():
208-
user = get_user_by_id(g.user_id)
209-
if not user:
210-
return jsonify({"error": "user not found"}), 404
211-
return render_template(
212-
"dashboard/settings.html",
213-
host_url=request.host_url,
214-
user=_minimal_user_profile(user),
215-
)
177+
try:
178+
password_hash = hash_password(password)
179+
180+
result = users_collection.update_one(
181+
{"_id": ObjectId(g.user_id)},
182+
{
183+
"$set": {
184+
"password_hash": password_hash,
185+
"password_set": True,
186+
"updated_at": datetime.now(timezone.utc),
187+
}
188+
},
189+
)
190+
191+
if result.modified_count > 0:
192+
return jsonify({"success": True, "message": "password set successfully"})
193+
else:
194+
return jsonify({"error": "failed to set password"}), 500
195+
196+
except Exception as e:
197+
print(f"Error setting password: {e}")
198+
return jsonify({"error": "failed to set password"}), 500

blueprints/dashboard.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from flask import Blueprint, jsonify, request, g, render_template, redirect
2+
3+
from utils.auth_utils import (
4+
requires_auth,
5+
)
6+
from utils.mongo_utils import (
7+
get_user_by_id,
8+
)
9+
from utils.auth_utils import get_user_profile
10+
11+
12+
dashboard_bp = Blueprint("dashboard", __name__)
13+
14+
15+
@dashboard_bp.route("/", methods=["GET"])
16+
@requires_auth
17+
def dashboard():
18+
# Redirect to links page as the default dashboard view
19+
return redirect("/dashboard/links")
20+
21+
22+
@dashboard_bp.route("/links", methods=["GET"])
23+
@requires_auth
24+
def dashboard_links():
25+
user = get_user_by_id(g.user_id)
26+
if not user:
27+
return jsonify({"error": "user not found"}), 404
28+
return render_template(
29+
"dashboard/links.html",
30+
host_url=request.host_url,
31+
user=get_user_profile(user),
32+
)
33+
34+
35+
@dashboard_bp.route("/keys", methods=["GET"])
36+
@requires_auth
37+
def dashboard_keys():
38+
user = get_user_by_id(g.user_id)
39+
if not user:
40+
return jsonify({"error": "user not found"}), 404
41+
return render_template(
42+
"dashboard/keys.html",
43+
host_url=request.host_url,
44+
user=get_user_profile(user),
45+
)
46+
47+
48+
@dashboard_bp.route("/statistics", methods=["GET"])
49+
@requires_auth
50+
def dashboard_statistics():
51+
user = get_user_by_id(g.user_id)
52+
if not user:
53+
return jsonify({"error": "user not found"}), 404
54+
return render_template(
55+
"dashboard/statistics.html",
56+
host_url=request.host_url,
57+
user=get_user_profile(user),
58+
)
59+
60+
61+
@dashboard_bp.route("/settings", methods=["GET"])
62+
@requires_auth
63+
def dashboard_settings():
64+
user = get_user_by_id(g.user_id)
65+
if not user:
66+
return jsonify({"error": "user not found"}), 404
67+
return render_template(
68+
"dashboard/settings.html",
69+
host_url=request.host_url,
70+
user=get_user_profile(user),
71+
)

0 commit comments

Comments
 (0)