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

Prevent login brute-force using reCAPTCHA #224

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ archive/

# Node Modules
node_modules/

# PyCharm
.idea/
6 changes: 3 additions & 3 deletions apps/core/backends/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ def validate_email(email, exclude=''):


# validate reCAPTCHA
def validate_recaptcha(response):
if not settings.RECAPTCHA_SECRET:
def validate_recaptcha(response, secret=settings.RECAPTCHA_INVISIBLE_SECRET):
if not secret:
return True

data = {
'secret': settings.RECAPTCHA_SECRET,
'secret': secret,
'response': response,
}
resp = requests.post('https://www.google.com/recaptcha/api/siteverify',
Expand Down
24 changes: 24 additions & 0 deletions apps/core/migrations/0003_loginfailurelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.14 on 2018-09-28 07:54
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0002_emaildomain'),
]

operations = [
migrations.CreateModel(
name='LoginFailureLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField()),
('username', models.CharField(max_length=127)),
('time', models.DateTimeField(auto_now=True, db_index=True)),
],
),
]
17 changes: 17 additions & 0 deletions apps/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,20 @@ class EmailDomain(models.Model):
"""
domain = models.CharField(max_length=100, unique=True)
is_banned = models.BooleanField(default=True)


class LoginFailureLog(models.Model):
"""
denotes single log of an user for login failure event.
used to prevent the system from bruteforcing attack via login form.
- ip: origin ip address
- username: attmpted username at failure, this can be empty
- time: timestamp (indexed for later query)
"""
ip = models.GenericIPAddressField()
username = models.CharField(max_length=127)
time = models.DateTimeField(auto_now=True, db_index=True)

def __str__(self):
time_str = localtime(self.time).isoformat()
return f'{self.username} {self.ip} {time_str}'
6 changes: 4 additions & 2 deletions apps/core/views/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ def signup(request, social=False):
})

if not social:
return render(request, 'account/signup/main.html')
return render(request, 'account/signup/main.html', {
'recaptcha_sitekey': settings.RECAPTCHA_INVISIBLE_SITEKEY,
})

return render(request, 'account/signup/sns.html', {
'type': typ,
'profile': profile,
'email_warning': email_warning,
'captcha_enabled': 'y' if settings.RECAPTCHA_SECRET else '',
})


Expand Down
42 changes: 29 additions & 13 deletions apps/core/views/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import logging
from urllib.parse import parse_qs, urlparse

Expand All @@ -11,8 +12,9 @@
anon_required, auth_fb_callback, auth_fb_init,
auth_kaist_callback, auth_kaist_init, auth_tw_callback,
auth_tw_init, get_clean_url, get_social_name,
validate_recaptcha,
)
from apps.core.models import Notice, Service
from apps.core.models import LoginFailureLog, Notice, Service


logger = logging.getLogger('sso.auth')
Expand All @@ -28,9 +30,7 @@ def login_core(request, session_name, template_name, get_user_func):
valid_from__lte=current_time, valid_to__gt=current_time,
).first()

query_dict = parse_qs(urlparse(
request.session.get('next', '/'),
).query)
query_dict = parse_qs(urlparse(request.session.get('next', '/')).query)
service_name = query_dict.get('client_id', [''])[0]
service = Service.objects.filter(name=service_name).first()

Expand All @@ -40,24 +40,42 @@ def login_core(request, session_name, template_name, get_user_func):
(service and service.scope == 'SPARCS')
)

login_failure_timerange = datetime.timedelta(
minutes=settings.RECAPTCHA_LOGIN_FAILURE_TIME_RANGE)
last_login_failures = LoginFailureLog.objects.filter(
ip=ip, time__gte=(current_time - login_failure_timerange))

show_recaptcha = last_login_failures.count() >= settings.RECAPTCHA_LOGIN_FAILURE_COUNT
if request.method == 'POST':
user = get_user_func(request.POST)
if user:
if show_recaptcha:
captcha_data = request.POST.get('g-recaptcha-response', '')
captcha_success = validate_recaptcha(captcha_data, settings.RECAPTCHA_CHECKBOX_SECRET)
else:
captcha_success = True

user, attempted_username = get_user_func(request.POST)
if user and captcha_success:
request.session.pop('info_signup', None)
auth.login(request, user)

return redirect(get_clean_url(
request.session.pop('next', '/'),
))

request.session[session_name] = 1
request.session[session_name] = 3 if user else 1
if ip:
log_failure = LoginFailureLog()
log_failure.ip = ip
log_failure.username = attempted_username
log_failure.save()

return render(request, template_name, {
'notice': notice,
'service': service.alias if service else '',
'fail': request.session.pop(session_name, ''),
'show_internal': show_internal,
'kaist_enabled': settings.KAIST_APP_ENABLED,
'recaptcha_sitekey': settings.RECAPTCHA_CHECKBOX_SITEKEY if show_recaptcha else '',
})


Expand All @@ -67,9 +85,8 @@ def login(request):
def get_user_func(post_dict):
email = post_dict.get('email', '[email protected]')
password = post_dict.get('password', 'unknown')
return auth.authenticate(
request=request, email=email, password=password,
)
user = auth.authenticate(request=request, email=email, password=password)
return user, email

return login_core(
request, 'result_login',
Expand All @@ -83,9 +100,8 @@ def login_internal(request):
def get_user_func(post_dict):
ldap_id = post_dict.get('ldap-id', 'unknown')
ldap_pw = post_dict.get('ldap-pw', 'unknown')
return auth.authenticate(
request=request, ldap_id=ldap_id, ldap_pw=ldap_pw,
)
user = auth.authenticate(request=request, ldap_id=ldap_id, ldap_pw=ldap_pw)
return user, ldap_id

return login_core(
request, 'result_login_internal',
Expand Down
2 changes: 1 addition & 1 deletion apps/core/views/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def contact(request):

return render(request, 'contact.html', {
'submitted': submitted,
'captcha_enabled': 'y' if settings.RECAPTCHA_SECRET else '',
'recaptcha_sitekey': settings.RECAPTCHA_INVISIBLE_SITEKEY,
})


Expand Down
40 changes: 22 additions & 18 deletions locale/ko/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: v0.8\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-09-09 01:00+0900\n"
"POT-Creation-Date: 2018-09-28 17:03+0900\n"
"PO-Revision-Date: 2016-01-01 19:21+0900\n"
"Last-Translator: Jae-Sung Kim <[email protected]>\n"
"Language-Team: SPARCS SSO Team <[email protected]>\n"
Expand Down Expand Up @@ -129,7 +129,7 @@ msgstr "최근 30일 동안의 활동 로그가 나타납니다."

#: templates/account/login/internal.html:4
#: templates/account/login/internal.html:18 templates/account/login/main.html:4
#: templates/account/login/main.html:18 templates/base.html:59
#: templates/account/login/main.html:20 templates/base.html:59
msgid "Login"
msgstr "로그인"

Expand All @@ -146,7 +146,7 @@ msgstr ""
"로그인 창을 이용하세요."

#: templates/account/login/internal.html:23
#: templates/account/login/main.html:23
#: templates/account/login/main.html:25
#, python-format
msgid "You need to login using SPARCS SSO account to use %(service)s service."
msgstr "%(service)s 서비스를 사용하려면 SPARCS SSO 계정이 필요합니다."
Expand All @@ -160,7 +160,7 @@ msgid "ID"
msgstr "아이디"

#: templates/account/login/internal.html:38
#: templates/account/login/main.html:42 templates/account/signup/main.html:23
#: templates/account/login/main.html:48 templates/account/signup/main.html:23
msgid "Password"
msgstr "비밀번호"

Expand All @@ -172,50 +172,54 @@ msgstr "LDAP 로그인"
msgid "Back to Normal Login"
msgstr "일반 로그인"

#: templates/account/login/main.html:21
#: templates/account/login/main.html:23
msgid "Welcome to SPARCS SSO!"
msgstr "SPARCS SSO에 오신 것을 환영합니다!"

#: templates/account/login/main.html:28
#: templates/account/login/main.html:30
msgid "Invalid email / password"
msgstr "잘못된 이메일 / 비밀번호"

#: templates/account/login/main.html:32 templates/account/profile.html:117
#: templates/account/login/main.html:34 templates/account/profile.html:117
msgid "Please grant all requested permission"
msgstr "요청한 권한을 모두 승인해주세요."

#: templates/account/login/main.html:39 templates/account/profile.html:23
#: templates/account/login/main.html:38
msgid "Please fill out the item 'I'm not a robot'"
msgstr "'로봇이 아닙니다' 항목에 체크해주세요."

#: templates/account/login/main.html:45 templates/account/profile.html:23
#: templates/account/signup/main.html:18 templates/account/signup/sns.html:26
#: templates/contact.html:28 templates/contact.html:34
msgid "Email"
msgstr "이메일"

#: templates/account/login/main.html:45
#: templates/account/login/main.html:58
msgid "Email Login"
msgstr "이메일로 로그인"

#: templates/account/login/main.html:47 templates/base.html:58
#: templates/account/login/main.html:60 templates/base.html:58
msgid "Sign Up"
msgstr "회원가입"

#: templates/account/login/main.html:48 templates/account/pw-reset/main.html:25
#: templates/account/login/main.html:61 templates/account/pw-reset/main.html:25
#: templates/base.html:60
msgid "Reset Password"
msgstr "비밀번호 재설정"

#: templates/account/login/main.html:50
#: templates/account/login/main.html:63
msgid "Internal Login"
msgstr "내부 로그인"

#: templates/account/login/main.html:60
#: templates/account/login/main.html:73
msgid "KAIST SSO Login / Signup"
msgstr "카이스트 통합인증으로 로그인 / 가입"

#: templates/account/login/main.html:63
#: templates/account/login/main.html:76
msgid "Facebook Login / Signup"
msgstr "페이스북으로 로그인 / 가입"

#: templates/account/login/main.html:66
#: templates/account/login/main.html:79
msgid "Twitter Login / Signup"
msgstr "트위터로 로그인 / 가입"

Expand Down Expand Up @@ -437,7 +441,7 @@ msgstr "잘못된 토큰이거나 유효기간이 만료되어 비밀번호 재
#: templates/account/pw-reset/fail.html:12
#: templates/account/pw-reset/main.html:26
#: templates/account/pw-reset/send.html:26
#: templates/account/signup/main.html:53 templates/account/signup/sns.html:66
#: templates/account/signup/main.html:55 templates/account/signup/sns.html:66
msgid "Login Page"
msgstr "로그인 페이지"

Expand Down Expand Up @@ -558,7 +562,7 @@ msgid "Continue"
msgstr "계속"

#: templates/account/signup/main.html:4 templates/account/signup/main.html:12
#: templates/account/signup/main.html:52 templates/account/signup/sns.html:4
#: templates/account/signup/main.html:54 templates/account/signup/sns.html:4
#: templates/account/signup/sns.html:8 templates/account/signup/sns.html:65
msgid "Signup"
msgstr "회원가입"
Expand Down Expand Up @@ -755,7 +759,7 @@ msgstr "기타"
msgid "Message"
msgstr "메시지"

#: templates/contact.html:69
#: templates/contact.html:71
msgid "Send to SSO Team"
msgstr "SSO Team에게 전송"

Expand Down
18 changes: 16 additions & 2 deletions sparcssso/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@

CSRF_FAILURE_VIEW = 'apps.core.views.general.csrf_failure'

# RECAPTCHA for login failures
RECAPTCHA_LOGIN_FAILURE_COUNT = 5

RECAPTCHA_LOGIN_FAILURE_TIME_RANGE = 30 # minutes


# Facebook, Twitter, KAIST API keys

Expand All @@ -110,7 +115,16 @@

KAIST_APP_SECRET = ''

RECAPTCHA_SECRET = ''

# Invisible RECAPTCHA API keys used for normal user verification
RECAPTCHA_INVISIBLE_SITEKEY = ''

RECAPTCHA_INVISIBLE_SECRET = ''

# Checkbox RECAPTCHA API keys used for prevent brute-force
RECAPTCHA_CHECKBOX_SITEKEY = ''

RECAPTCHA_CHECKBOX_SECRET = ''


# E-mail settings
Expand Down Expand Up @@ -205,7 +219,7 @@
STAT_FILE = os.path.join(BASE_DIR, 'archive/stats.txt')


# Local Settings
# Local settings
try:
from .local_settings import * # noqa: F401, F403
except ImportError:
Expand Down
Loading