Add support for magic link email sign-in (#820)

* Add magic link email sign-in option

* Adding backend routes and model changes to keep state of email verification code and status

* Test and fix end to end email verification flow

* Add documentation for how to use the magic link sign-in when self-hosting Khoj

* Add magic link sign in to public conversation page
This commit is contained in:
sabaimran
2024-06-20 01:02:58 -07:00
committed by GitHub
parent 093eb473cb
commit 3cfe5aabe5
13 changed files with 337 additions and 37 deletions

View File

@@ -160,10 +160,14 @@ async def acreate_user_by_phone_number(phone_number: str) -> KhojUser:
return user
async def get_or_create_user_by_email(email: str) -> KhojUser:
async def aget_or_create_user_by_email(email: str) -> KhojUser:
user, _ = await KhojUser.objects.filter(email=email).aupdate_or_create(defaults={"username": email, "email": email})
await user.asave()
if user:
user.email_verification_code = secrets.token_urlsafe(18)
await user.asave()
user_subscription = await Subscription.objects.filter(user=user).afirst()
if not user_subscription:
await Subscription.objects.acreate(user=user, type="trial")
@@ -171,10 +175,23 @@ async def get_or_create_user_by_email(email: str) -> KhojUser:
return user
async def aget_user_validated_by_email_verification_code(code: str) -> KhojUser:
user = await KhojUser.objects.filter(email_verification_code=code).afirst()
if not user:
return None
user.email_verification_code = None
user.verified_email = True
await user.asave()
return user
async def create_user_by_google_token(token: dict) -> KhojUser:
user, _ = await KhojUser.objects.filter(email=token.get("email")).aupdate_or_create(
defaults={"username": token.get("email"), "email": token.get("email")}
)
user.verified_email = True
await user.asave()
await GoogleUser.objects.acreate(
@@ -228,7 +245,7 @@ async def set_user_subscription(
email: str, is_recurring=None, renewal_date=None, type="standard"
) -> Optional[Subscription]:
# Get or create the user object and their subscription
user = await get_or_create_user_by_email(email)
user = await aget_or_create_user_by_email(email)
user_subscription = await Subscription.objects.filter(user=user).afirst()
# Update the user subscription state

View File

@@ -2,7 +2,7 @@ import csv
import json
from apscheduler.job import Job
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin
from django.http import HttpResponse
from django_apscheduler.admin import DjangoJobAdmin
@@ -73,7 +73,19 @@ class KhojUserAdmin(UserAdmin):
search_fields = ("email", "username", "phone_number", "uuid")
filter_horizontal = ("groups", "user_permissions")
fieldsets = (("Personal info", {"fields": ("phone_number",)}),) + UserAdmin.fieldsets
fieldsets = (("Personal info", {"fields": ("phone_number", "email_verification_code")}),) + UserAdmin.fieldsets
actions = ["get_email_login_url"]
def get_email_login_url(self, request, queryset):
for user in queryset:
if user.email:
host = request.get_host()
unique_id = user.email_verification_code
login_url = f"{host}/auth/magic?code={unique_id}"
messages.info(request, f"Email login URL for {user.email}: {login_url}")
get_email_login_url.short_description = "Get email login URL" # type: ignore
admin.site.register(KhojUser, KhojUserAdmin)

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.11 on 2024-06-17 08:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0045_fileobject"),
]
operations = [
migrations.AddField(
model_name="khojuser",
name="email_verification_code",
field=models.CharField(blank=True, default=None, max_length=200, null=True),
),
migrations.AddField(
model_name="khojuser",
name="verified_email",
field=models.BooleanField(default=False),
),
]

View File

@@ -32,6 +32,8 @@ class KhojUser(AbstractUser):
uuid = models.UUIDField(models.UUIDField(default=uuid.uuid4, editable=False))
phone_number = PhoneNumberField(null=True, default=None, blank=True)
verified_phone_number = models.BooleanField(default=False)
verified_email = models.BooleanField(default=False)
email_verification_code = models.CharField(max_length=200, null=True, default=None, blank=True)
def save(self, *args, **kwargs):
if not self.uuid:

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>Welcome to Khoj</title>
</head>
<body>
<body style="font-family: 'Verdana', sans-serif; font-weight: 400; font-style: normal; padding: 0; text-align: left; width: 600px; margin: 20px auto;">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<a class="logo" href="https://khoj.dev" target="_blank" style="text-decoration: none; text-decoration: underline dotted;">
<img src="https://khoj.dev/khoj-logo-sideways-500.png" alt="Khoj Logo" style="width: 100px;">
</a>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">Hi! <a href="{{ link }}" target="_blank" style="text-decoration: none; text-decoration: underline dotted;">Click here to sign in on this browser.</a></p>
<p style="color: #333; font-size: large; margin-top: 20px; padding: 0; line-height: 1.5;">- The Khoj Team</p>
</body>
</html>

View File

@@ -12,35 +12,74 @@
<body>
<div class="khoj-header"></div>
<!-- Login Modal -->
<div id="login-modal">
<img class="khoj-logo" src="/static/assets/icons/favicon-128x128.png" alt="Khoj"></img>
<div class="login-modal-title">Login to Khoj</div>
<!-- Sign in with Magic Link -->
<div class="khoj-magic-link">
<input type="email" id="email" placeholder="Email" required>
<button id="magic-link-button">Send Magic Link</button>
</div>
<!-- Divider -->
<div style="text-align: center; font-size: 16px; font-weight: 500; border-top: 1px solid black;">OR</div>
<!-- Sign Up/Login with Google OAuth -->
<div
class="g_id_signin"
data-shape="circle"
data-text="continue_with"
data-logo_alignment="center"
data-size="large"
data-type="standard">
</div>
<div id="g_id_onload"
data-client_id="{{ google_client_id }}"
data-ux_mode="popup"
data-use_fedcm_for_prompt="true"
data-login_uri="{{ redirect_uri }}"
data-auto-select="true">
</div>
</div>
<!-- Login Modal -->
<div id="login-modal">
<img class="khoj-logo" src="/static/assets/icons/favicon-128x128.png" alt="Khoj"></img>
<div class="login-modal-title">Login to Khoj</div>
<!-- Sign Up/Login with Google OAuth -->
<div
class="g_id_signin"
data-shape="circle"
data-text="continue_with"
data-logo_alignment="center"
data-size="large"
data-type="standard">
</div>
<div id="g_id_onload"
data-client_id="{{ google_client_id }}"
data-ux_mode="popup"
data-use_fedcm_for_prompt="true"
data-login_uri="{{ redirect_uri }}"
data-auto-select="true">
</div>
<div class="khoj-footer"></div>
</div>
<div class="khoj-footer"></div>
</div>
</body>
<script>
const magicLinkButton = document.getElementById('magic-link-button');
const emailInput = document.getElementById('email');
magicLinkButton.addEventListener('click', async () => {
const email = emailInput.value;
if (!email) {
alert('Please enter a valid email address');
return;
}
if (!email.includes('@')) {
alert('Please enter a valid email address');
return;
}
magicLinkButton.disabled = true;
magicLinkButton.innerText = 'Check your email for a sign-in link!';
const response = await fetch('/auth/magic', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ "email": email }),
})
if (response.status === 200) {
console.log('Magic link sent to your email');
} else {
alert('Failed to send magic link');
}
});
</script>
<style>
@media only screen and (max-width: 700px) {
body {
@@ -56,7 +95,7 @@
@media only screen and (min-width: 700px) {
body {
display: grid;
grid-template-columns: 1fr min(70vw, 100%) 1fr;
grid-template-columns: 1fr min(50vw, 100%) 1fr;
grid-template-rows: 1fr auto 1fr;
}
body > * {
@@ -110,7 +149,6 @@
div#login-modal {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr auto auto 1fr;
gap: 32px;
min-height: 300px;
margin-left: 25%;
@@ -118,6 +156,40 @@
z-index: 1;
}
.khoj-magic-link {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
text-align: center;
}
#email {
padding: 10px;
font-size: 16px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
width: 100%;
}
#email:focus {
box-shadow: 0 0 10px var(--main-text-color);
}
#magic-link-button {
padding: 10px;
font-size: 16px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
width: 100%;
background: var(--main-text-color);
color: var(--frosted-background-color);
cursor: pointer;
}
#magic-link-button:hover {
box-shadow: 0 0 10px var(--main-text-color);
}
div.g_id_signin {
margin: 0 auto;
display: block;

View File

@@ -656,7 +656,7 @@ Learn more [here](https://khoj.dev).
event.preventDefault();
if (!isLoggedIn) {
document.getElementById("login-modal").style.display = "block";
document.getElementById("login-modal").style.display = "flex";
return;
}
@@ -851,6 +851,13 @@ Learn more [here](https://khoj.dev).
<div id="login-modal" style="display: none;">
<img class="khoj-logo" src="/static/assets/icons/favicon-128x128.png" alt="Khoj"></img>
<div class="login-modal-title">Login to continue</div>
<!-- Sign in with Magic Link -->
<div class="khoj-magic-link">
<input type="email" id="email" placeholder="Email" required>
<button id="magic-link-button">Send Magic Link</button>
</div>
<!-- Divider -->
<div style="text-align: center; font-size: 16px; font-weight: 500; border-top: 1px solid black;">OR</div>
<!-- Sign Up/Login with Google OAuth -->
<div
class="g_id_signin"
@@ -964,6 +971,40 @@ Learn more [here](https://khoj.dev).
if (chatNav) {
chatNav.classList.add("khoj-nav-selected");
}
const magicLinkButton = document.getElementById('magic-link-button');
const emailInput = document.getElementById('email');
magicLinkButton.addEventListener('click', async () => {
const email = emailInput.value;
if (!email) {
alert('Please enter a valid email address');
return;
}
if (!email.includes('@')) {
alert('Please enter a valid email address');
return;
}
magicLinkButton.disabled = true;
magicLinkButton.innerText = 'Check your email for a sign-in link!';
const response = await fetch('/auth/magic', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ "email": email }),
})
if (response.status === 200) {
console.log('Magic link sent to your email');
} else {
alert('Failed to send magic link');
}
});
</script>
<style>
html, body {
@@ -1405,10 +1446,6 @@ Learn more [here](https://khoj.dev).
}
div#login-modal {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr auto auto 1fr;
gap: 32px;
min-height: 300px;
z-index: 1;
position: absolute;
@@ -1419,6 +1456,45 @@ Learn more [here](https://khoj.dev).
border-radius: 8px;
padding: 40px;
background: linear-gradient(145deg, var(--background-color), var(--primary-hover));
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
align-items: center;
gap: 24px;
}
.khoj-magic-link {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
text-align: center;
}
#email {
padding: 10px;
font-size: 16px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
}
#email:focus {
box-shadow: 0 0 10px var(--main-text-color);
}
#magic-link-button {
padding: 10px;
font-size: 16px;
border: 1px solid var(--main-text-color);
border-radius: 5px;
width: 100%;
background: var(--main-text-color);
color: var(--frosted-background-color);
cursor: pointer;
}
#magic-link-button:hover {
box-shadow: 0 0 10px var(--main-text-color);
}
div.g_id_signin {

View File

@@ -5,6 +5,7 @@ import os
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel, EmailStr
from starlette.authentication import requires
from starlette.config import Config
from starlette.requests import Request
@@ -13,11 +14,13 @@ from starlette.status import HTTP_302_FOUND
from khoj.database.adapters import (
acreate_khoj_token,
aget_or_create_user_by_email,
aget_user_validated_by_email_verification_code,
delete_khoj_token,
get_khoj_tokens,
get_or_create_user,
)
from khoj.routers.email import send_welcome_email
from khoj.routers.email import send_magic_link_email, send_welcome_email
from khoj.routers.helpers import get_next_url, update_telemetry_state
from khoj.utils import state
@@ -26,6 +29,10 @@ logger = logging.getLogger(__name__)
auth_router = APIRouter()
class MagicLinkForm(BaseModel):
email: EmailStr
if not state.anonymous_mode:
missing_requirements = []
from authlib.integrations.starlette_client import OAuth, OAuthError
@@ -62,6 +69,35 @@ async def login(request: Request):
return await oauth.google.authorize_redirect(request, redirect_uri)
@auth_router.post("/magic")
async def login_magic_link(request: Request, form: MagicLinkForm):
if request.user.is_authenticated:
# Clear the session if user is already authenticated
request.session.pop("user", None)
email = form.email
user = await aget_or_create_user_by_email(email)
unique_id = user.email_verification_code
if user:
await send_magic_link_email(email, unique_id, request.base_url)
return Response(status_code=200)
@auth_router.get("/magic")
async def sign_in_with_magic_link(request: Request, code: str):
user = await aget_user_validated_by_email_verification_code(code)
if user:
id_info = {
"email": user.email,
}
request.session["user"] = dict(id_info)
return RedirectResponse(url="/")
return RedirectResponse(request.app.url_path_for("login_page"))
@auth_router.post("/token")
@requires(["authenticated"], redirect="login_page")
async def generate_token(request: Request, token_name: Optional[str] = None):

View File

@@ -32,6 +32,22 @@ def is_resend_enabled():
return bool(RESEND_API_KEY)
async def send_magic_link_email(email, unique_id, host):
sign_in_link = f"{host}auth/magic?code={unique_id}"
if not is_resend_enabled():
logger.debug(f"Email sending disabled. Share this sign-in link with the user: {sign_in_link}")
return
template = env.get_template("magic_link.html")
html_content = template.render(link=f"{host}auth/magic?code={unique_id}")
resend.Emails.send(
{"sender": "noreply@khoj.dev", "to": email, "subject": "Your Sign-In Link for Khoj 🚀", "html": html_content}
)
async def send_welcome_email(name, email):
if not is_resend_enabled():
logger.debug("Email sending disabled")