Skip to content

Commit 1c2ae7d

Browse files
committed
feat(otp): add ARP OTP login command
1 parent 7c47485 commit 1c2ae7d

10 files changed

Lines changed: 232 additions & 2 deletions

File tree

src/config/env_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,9 @@ class Settings(BaseSettings):
5353
QA_SERVER_BASE_URL: str = ""
5454
QA_API_KEY: str = ""
5555

56+
# ARP OTP login
57+
ARP_BASE_URL: str = ""
58+
ARP_OTP_SECRET: str = ""
59+
60+
5661
envs = Settings() # Singleton

src/env

Submodule env updated from 52bf138 to 23c5d4e

src/handler/bigchat/help_response.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def handle_mention(self):
1515
msg=strip_multiline(
1616
"""
1717
나를 멘션했을 때, 사용할 수 있는 명령어야.
18+
- `otp`: ARP 로그인 코드를 발급해줘!
1819
- `shuffle` 또는 `섞어줘`: 멘션된 유저들을 섞어줘!
1920
- `새로운 빅챗`: 새로운 빅챗 시트를 만들어줘!
2021
- `help` 또는 `도움`: 도움말을 보여줘!

src/handler/controller.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
from handler.bigchat.help_response import HelpResponse
1010
from handler.bigchat.question_response import QuestionResponse
1111
from handler.bigchat.subin_like_response import SubinLikeResponse
12+
from handler.otp.arp_response import ArpOtpResponse
1213
from handler.decorator import catch_global_error, loading_emoji_while_processing
1314
from implementation.google_spreadsheet_client import GoogleSpreadsheetClient
1415
from implementation.qa_client import QAClient
1516
from implementation.member_finder import MemberManager
17+
from implementation.arp_otp import ArpOtp
1618
from implementation.slack_client import SlackClient
1719

1820
MEMBER_MANAGER = None
@@ -100,8 +102,22 @@ def mention_response(event, say, client):
100102
question_response = QuestionResponse(
101103
event, SlackClient(say, client), _get_qa_client()
102104
)
105+
arp_otp_response = ArpOtpResponse(
106+
event,
107+
SlackClient(say, client),
108+
ArpOtp(
109+
base_url=envs.ARP_BASE_URL,
110+
secret=envs.ARP_OTP_SECRET,
111+
),
112+
)
103113
MentionResponse(
104-
[question_response, shuffle_response, create_bigchat_sheet, help_response],
114+
[
115+
arp_otp_response,
116+
question_response,
117+
shuffle_response,
118+
create_bigchat_sheet,
119+
help_response,
120+
],
105121
simple_response,
106122
).run()
107123

src/handler/otp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

src/handler/otp/arp_response.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import re
2+
3+
from handler.bigchat.mention_handler import MentionHandler
4+
from util.utils import strip_multiline
5+
6+
7+
class ArpOtpResponse(MentionHandler):
8+
ANNA_MENTION_PATTERN = re.compile(r"<@[A-Z0-9]+>")
9+
10+
def __init__(self, event, slack_client, arp_otp):
11+
self.text = event["text"]
12+
self.ts = event["ts"]
13+
self.channel = event["channel"]
14+
self.user_id = event["user"]
15+
self.slack_client = slack_client
16+
self.arp_otp = arp_otp
17+
18+
def handle_mention(self):
19+
if not self.can_handle():
20+
return False
21+
22+
try:
23+
login_url = self.arp_otp.issue_login_url(self.user_id)
24+
except ValueError:
25+
self.slack_client.send_message(
26+
msg="ARP OTP 설정이 아직 안 되어 있어. 운영진에게 알려줘!",
27+
ts=self.ts,
28+
)
29+
return False
30+
31+
self.slack_client.send_message_only_visible_to_user(
32+
msg=strip_multiline(
33+
"""
34+
ARP 로그인 링크야.
35+
<{}|10분 안에 이 링크로 접속해줘!>
36+
37+
링크는 본인에게만 보이고, 만료되면 `@ANNA otp`로 다시 발급받으면 돼.""",
38+
login_url,
39+
),
40+
user_id=self.user_id,
41+
channel=self.channel,
42+
ts=self.ts,
43+
)
44+
return True
45+
46+
def can_handle(self):
47+
text = self.ANNA_MENTION_PATTERN.sub("", self.text).strip().lower()
48+
return text == "otp"

src/implementation/arp_otp.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import base64
2+
import hashlib
3+
import hmac
4+
import json
5+
import time
6+
from urllib.parse import urlencode
7+
8+
9+
class ArpOtp:
10+
PURPOSE = "arp-login-v1"
11+
TTL_SECONDS = 10 * 60
12+
13+
def __init__(self, base_url: str, secret: str, now=None):
14+
self.base_url = (base_url or "").rstrip("/")
15+
self.secret = secret or ""
16+
self.now = now or time.time
17+
18+
def issue_login_url(self, slack_user_id: str) -> str:
19+
code = self.issue_code(slack_user_id)
20+
return f"{self.base_url}/auth/otp?{urlencode({'code': code})}"
21+
22+
def issue_code(self, slack_user_id: str) -> str:
23+
if not self.base_url:
24+
raise ValueError("ARP_BASE_URL is required")
25+
if not self.secret:
26+
raise ValueError("ARP_OTP_SECRET is required")
27+
if not slack_user_id:
28+
raise ValueError("slack_user_id is required")
29+
30+
payload = {
31+
"exp": int(self.now()) + self.TTL_SECONDS,
32+
"purpose": self.PURPOSE,
33+
"sub": slack_user_id,
34+
}
35+
payload_part = self._base64url(
36+
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37+
)
38+
signature = self._base64url(
39+
hmac.new(
40+
self.secret.encode("utf-8"),
41+
payload_part.encode("utf-8"),
42+
hashlib.sha256,
43+
).digest()
44+
)
45+
return f"{payload_part}.{signature}"
46+
47+
@staticmethod
48+
def _base64url(value: bytes) -> str:
49+
return base64.urlsafe_b64encode(value).decode("utf-8").rstrip("=")

test/handler/otp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
4+
from handler.otp.arp_response import ArpOtpResponse
5+
from test.handler.bigchat.sample_data import create_sample_app_mention_event
6+
7+
8+
class TestArpOtpResponse(unittest.TestCase):
9+
def test_run(self):
10+
event = create_sample_app_mention_event("<@U01BN035Y6L> otp")
11+
mock_slack_client = MagicMock()
12+
mock_otp = MagicMock()
13+
mock_otp.issue_login_url.return_value = "https://arp.ausg.me/auth/otp?code=test"
14+
sut = ArpOtpResponse(event, mock_slack_client, mock_otp)
15+
16+
result = sut.handle_mention()
17+
18+
mock_otp.issue_login_url.assert_called_once_with("UQJ8HQJG5")
19+
mock_slack_client.send_message_only_visible_to_user.assert_called_once()
20+
kwargs = mock_slack_client.send_message_only_visible_to_user.call_args.kwargs
21+
assert kwargs["user_id"] == "UQJ8HQJG5"
22+
assert kwargs["channel"] == "C03SZTDEDK3"
23+
assert kwargs["ts"] == "1689403771.805849"
24+
assert "10분" in kwargs["msg"]
25+
assert result is True
26+
27+
def test_not_run_by_command_notfound(self):
28+
event = create_sample_app_mention_event("<@U01BN035Y6L> help")
29+
mock_slack_client = MagicMock()
30+
mock_otp = MagicMock()
31+
sut = ArpOtpResponse(event, mock_slack_client, mock_otp)
32+
33+
result = sut.handle_mention()
34+
35+
mock_otp.assert_not_called()
36+
mock_slack_client.assert_not_called()
37+
assert result is False
38+
39+
def test_not_run_when_otp_is_not_exact_command(self):
40+
event = create_sample_app_mention_event("<@U01BN035Y6L> help otp")
41+
mock_slack_client = MagicMock()
42+
mock_otp = MagicMock()
43+
sut = ArpOtpResponse(event, mock_slack_client, mock_otp)
44+
45+
result = sut.handle_mention()
46+
47+
mock_otp.assert_not_called()
48+
mock_slack_client.assert_not_called()
49+
assert result is False
50+
51+
def test_run_by_config_error(self):
52+
event = create_sample_app_mention_event("<@U01BN035Y6L> otp")
53+
mock_slack_client = MagicMock()
54+
mock_otp = MagicMock()
55+
mock_otp.issue_login_url.side_effect = ValueError()
56+
sut = ArpOtpResponse(event, mock_slack_client, mock_otp)
57+
58+
result = sut.handle_mention()
59+
60+
mock_slack_client.send_message.assert_called_once()
61+
assert result is False
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import base64
2+
import json
3+
import unittest
4+
5+
from implementation.arp_otp import ArpOtp
6+
7+
8+
def decode_base64url(value):
9+
padding = "=" * ((4 - len(value) % 4) % 4)
10+
return base64.urlsafe_b64decode(f"{value}{padding}")
11+
12+
13+
class TestArpOtp(unittest.TestCase):
14+
def test_issue_code(self):
15+
sut = ArpOtp(
16+
base_url="https://arp.ausg.me/",
17+
secret="test-secret",
18+
now=lambda: 1000,
19+
)
20+
21+
code = sut.issue_code("U123")
22+
payload_part, signature = code.split(".")
23+
24+
payload = json.loads(decode_base64url(payload_part))
25+
assert payload == {
26+
"exp": 1600,
27+
"purpose": "arp-login-v1",
28+
"sub": "U123",
29+
}
30+
assert len(signature) > 0
31+
32+
def test_issue_login_url(self):
33+
sut = ArpOtp(
34+
base_url="https://arp.ausg.me/",
35+
secret="test-secret",
36+
now=lambda: 1000,
37+
)
38+
39+
login_url = sut.issue_login_url("U123")
40+
41+
assert login_url.startswith("https://arp.ausg.me/auth/otp?code=")
42+
43+
def test_require_base_url_and_secret(self):
44+
with self.assertRaises(ValueError):
45+
ArpOtp(base_url="", secret="test-secret").issue_code("U123")
46+
47+
with self.assertRaises(ValueError):
48+
ArpOtp(base_url="https://arp.ausg.me", secret="").issue_code("U123")

0 commit comments

Comments
 (0)