Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for new KAIST SSO IdP #301

Merged
merged 23 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ FACEBOOK_APP_SECRET=
TWITTER_APP_ID=
TWITTER_APP_SECRET=
# 0 or 1
KAIST_APP_ENABLED=0
KAIST_APP_ENABLED=1
KAIST_APP_SECRET=

KAIST_APP_V2_ENABLED=1
KAIST_APP_V2_HOSTNAME=ssodev.kaist.ac.kr
KAIST_APP_V2_SECRET=
KAIST_APP_V2_CLIENT_ID=

RECAPTCHA_SECRET=

# Production only
Expand Down
19 changes: 19 additions & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use flake;

export SSO_DB_NAME=sparcssso
export SSO_DB_USER=root
export SSO_DB_PASSWORD=unsafe-password
export SSO_DB_HOST=127.0.0.1
export SSO_DB_PORT=33306

export SSO_ENV=development
export SECRET_KEY=foobar
export SENTRY_DSN=
export FACEBOOK_APP_ID=
export FACEBOOK_APP_SECRET=
export TWITTER_APP_ID=
export TWITTER_APP_SECRET=
# 0 or 1
export KAIST_APP_ENABLED=0
export KAIST_APP_SECRET=
export RECAPTCHA_SECRET=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ node_modules/
/django_env/
/sso.ini
.idea
.venv
.direnv
.envrc
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
setup_local_cert:
cd local-dev && ./setup-cert.sh

# mysql 5.6이 arm64 이미지가 없어 amd64 고정이 필요
compose_up: setup_local_cert
cd local-dev && DOCKER_DEFAULT_PLATFORM=linux/amd64 docker compose up -d

test:
pytest tests/

Expand Down
7 changes: 7 additions & 0 deletions apps/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import json
import time

from django.conf import settings
from django.http import HttpResponse
from django.urls import path

from apps.api.views import v2
from apps.core.views import auth


# /versions/
Expand All @@ -28,3 +30,8 @@ def versions(request):
path('v2/email/', v2.EmailView.as_view()),
path('v2/stats/', v2.stats),
]

if settings.KAIST_APP_V2_ENABLED:
urlpatterns += [
path('idp/kaist/callback', auth.callback_kaist_v2)
]
75 changes: 73 additions & 2 deletions apps/core/backends/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import uuid
from urllib.parse import parse_qsl, urlencode

from django.http import HttpResponseBadRequest
import ldap3
import oauth2 as oauth
import requests
Expand Down Expand Up @@ -179,7 +180,7 @@ def auth_tw_callback(tokens, verifier):


# KAIST Auth
def auth_kaist_init(callback_url):
def auth_kaist_init(callback_url: str):
state = str(uuid.uuid4())
args = {
'client_id': 'SPARCS',
Expand All @@ -190,7 +191,7 @@ def auth_kaist_init(callback_url):
return f'https://iam2.kaist.ac.kr/api/sso/commonLogin?{urlencode(args)}', state


def auth_kaist_callback(token, iam_info_raw):
def auth_kaist_callback(token: str, iam_info_raw: str):
iam_info = json.loads(iam_info_raw)['dataMap']
state = iam_info['state']

Expand All @@ -211,3 +212,73 @@ def auth_kaist_callback(token, iam_info_raw):
kaist_profile = UserProfile.objects.filter(kaist_id=info['userid'],
test_only=False).first()
return kaist_profile, info, True


def auth_kaist_v2_init(request, callback_url: str):
state = str(uuid.uuid4())
nonce = str(uuid.uuid4())

return {
'body': {
'client_id': settings.KAIST_APP_V2_CLIENT_ID,
'redirect_uri': callback_url,
'state': state,
'nonce': nonce,
},
'action': f"https://{settings.KAIST_APP_V2_HOSTNAME}/auth/user/single/login/authorize",
}, state, nonce



def auth_kaist_v2_callback(request: str, redirect_url: str):
if request.POST.get("code") is None:
raise HttpResponseBadRequest("auth_kaist_v2_callback: Code not found")
return None, None, False
request_code = request.POST.get("code")

if request.POST.get("state") is None:
print("auth_kaist_v2_callback: State not found")
return None, None, False
request_state = request.POST.get("state")

if request_state != request.session.get('kaist_v2_login_state'):
print("auth_kaist_v2_callback: State mismatch")
return None, None, False

request_url = f"https://{settings.KAIST_APP_V2_HOSTNAME}/auth/api/single/auth"
data = {
'client_id': settings.KAIST_APP_V2_CLIENT_ID,
'client_secret': settings.KAIST_APP_V2_CLIENT_SECRET,
'code': request_code,
'redirect_uri': redirect_url,
}
response = requests.post(request_url, data=data, headers={
"Content-Type": "application/x-www-form-urlencoded"
})

response_data = response.json()
if "errorCode" in response_data:
print(f"auth_kaist_v2_callback: Error {response_data['errorCode']}: {response_data['error']}")
return None, None, False

request_nonce = request.session.get('kaist_v2_login_nonce')
if request_nonce != response_data['nonce']:
print("auth_kaist_v2_callback: Nonce mismatch")
return None, None, False

user_data = response_data['userInfo']
user_name_parts = [v.strip() for v in user_data.get("user_eng_nm").split(",") if v.strip() != ""]

info = {
'userid': user_data["kaist_uid"],
'email': user_data.get("email"),
'first_name': user_name_parts[1] if len(user_name_parts) > 1 else "",
'last_name': user_name_parts[0] if len(user_name_parts) > 0 else "",
'gender': '*H',
'birthday': "",
'kaist_info_v2': user_data,
}
kaist_profile = UserProfile.objects.filter(kaist_id=info['userid'],
test_only=False).first()

return kaist_profile, info, True
8 changes: 7 additions & 1 deletion apps/core/backends/signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ def signup_social(typ, profile):

user.profile = UserProfile(gender=profile.get('gender', '*H'))
if 'birthday' in profile:
user.profile.birthday = profile['birthday']
if profile['birthday'].strip() == "":
user.profile.birthday = None
else:
user.profile.birthday = profile['birthday']

if typ == 'FB':
user.profile.facebook_id = profile['userid']
Expand All @@ -73,5 +76,8 @@ def signup_social(typ, profile):
elif typ == 'KAIST':
user.profile.email_authed = email.endswith('@kaist.ac.kr')
user.profile.save_kaist_info(profile)
elif typ == 'KAISTV2':
user.profile.email_authed = email.endswith('@kaist.ac.kr')
user.profile.save_kaist_v2_info(profile)
user.profile.save()
return user
23 changes: 23 additions & 0 deletions apps/core/migrations/0005_kaist_v2_sso.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.12 on 2025-03-27 14:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0004_inquirymail'),
]

operations = [
migrations.AddField(
model_name='userprofile',
name='kaist_v2_info',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='userprofile',
name='kaist_v2_info_time',
field=models.DateField(blank=True, null=True),
),
]
42 changes: 26 additions & 16 deletions apps/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,24 @@ def __str__(self):
class UserProfile(models.Model):
"""
denotes additional information of single user
- user: user object
- gender: gender; *H / *M / *F / *E or others
- birthday: birthday
- point: point for public services
- point_test: point for test services
- email_new: new email before auth
- email_authed: email authed state
- test_only: indicate test only account
- test_enabled: test mode state
- facebook_id: facebook unique id
- twitter_id: twitter unique id
- kaist_id: kaist uid
- kaist_info: additional kaist info
- kaist_info_time: kaist info updated time
- sparcs_id: sparcs id iff sparcs member
- expire_time: expire time for permanent deletion
- user: user object
- gender: gender; *H / *M / *F / *E or others
- birthday: birthday
- point: point for public services
- point_test: point for test services
- email_new: new email before auth
- email_authed: email authed state
- test_only: indicate test only account
- test_enabled: test mode state
- facebook_id: facebook unique id
- twitter_id: twitter unique id
- kaist_id: kaist uid
- kaist_info: additional kaist info
- kaist_info_time: kaist info updated time
- kaist_v2_info: additional kaist info
- kaist_v2_info_time: kaist info updated time
- sparcs_id: sparcs id iff sparcs member
- expire_time: expire time for permanent deletion
"""
user = models.OneToOneField(User, on_delete=models.CASCADE,
related_name='profile')
Expand All @@ -213,6 +215,8 @@ class UserProfile(models.Model):
kaist_id = models.CharField(max_length=50, blank=True, null=True)
kaist_info = models.TextField(blank=True, null=True)
kaist_info_time = models.DateField(blank=True, null=True)
kaist_v2_info = models.TextField(blank=True, null=True)
kaist_v2_info_time = models.DateField(blank=True, null=True)
sparcs_id = models.CharField(max_length=50, blank=True, null=True)
expire_time = models.DateTimeField(blank=True, null=True)

Expand Down Expand Up @@ -250,6 +254,12 @@ def save_kaist_info(self, info):
self.kaist_info_time = timezone.now()
self.save()

def save_kaist_v2_info(self, info):
self.kaist_id = info['userid']
self.kaist_v2_info = json.dumps(info['kaist_info_v2'])
self.kaist_v2_info_time = timezone.now()
self.save()

def __str__(self):
return f'{self.user}''s profile'

Expand Down
8 changes: 8 additions & 0 deletions apps/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,11 @@
path('connect/kaist/', auth.init, {'mode': 'CONN', 'site': 'KAIST'}),
path('renew/kaist/', auth.init, {'mode': 'RENEW', 'site': 'KAIST'}),
]


if settings.KAIST_APP_V2_ENABLED:
urlpatterns += [
path('login/kaistv2/', auth.init, {'mode': 'LOGIN', 'site': 'KAISTV2'}),
path('connect/kaistv2/', auth.init, {'mode': 'CONN', 'site': 'KAISTV2'}),
path('renew/kaistv2/', auth.init, {'mode': 'RENEW', 'site': 'KAISTV2'}),
]
Loading