Skip to content

Commit 614fa62

Browse files
ning-yindocomsoft
authored andcommitted
Add login for guest account (#72)
* Add module to sign in as guest account * Update README with instructions for GUEST_* * Refactor with review comments * Make less verbose
1 parent 040e738 commit 614fa62

File tree

7 files changed

+208
-0
lines changed

7 files changed

+208
-0
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ CADET_WEBPACK_ENTRY=app
1818

1919
# IVLE LAPI Key
2020
IVLE_KEY=your_ivle_lapi_key
21+
22+
# Guest account details for Cadet.Public.Updater
23+
GUEST_USERNAME=guest_account_username
24+
GUEST_PASSWORD=guest_account_password

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ a recompilation with `mix clean && mix`.
3232

3333
IVLE_KEY=your_ivle_lapi_key
3434

35+
If available, also replace the values for GUEST\_USER and GUEST\_PASSWORD
36+
3537
Run the server in your local machine
3638

3739
mix cadet.server

lib/cadet/public/updater.ex

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
defmodule Cadet.Public.Updater do
2+
@moduledoc """
3+
Represents a guest account in the CS1101S IVLE module. The guest account
4+
allows us to programmatically access the IVLE module contents, such as
5+
announcements and files.
6+
7+
The credentials for the guest account are defined in the `.env` file.
8+
"""
9+
@api_key Dotenv.load().values["IVLE_KEY"]
10+
@api_url "https://ivle.nus.edu.sg"
11+
@api_url_login @api_url |> URI.merge("api/login/?apikey=#{@api_key}&url=_") |> URI.to_string()
12+
@username Dotenv.load().values["GUEST_USERNAME"]
13+
@password Dotenv.load().values["GUEST_PASSWORD"]
14+
15+
@doc """
16+
Get an authentication token for the guest account.
17+
18+
1. `get_browser_session` is a GET to obtain information representing a
19+
browser session
20+
2. Login credentials and information from (1) are sent via POST. IVLE responds
21+
with a 302.
22+
3. The location returned in (2) is visited with a GET. Again, IVLE responds
23+
with a 302. This time, an authentication token is embedded in the location,
24+
which is extracted with `Regex`.
25+
"""
26+
def get_token do
27+
session = get_browser_session()
28+
http_opts = [hackney: [cookie: session.cookie, follow_redirect: false]]
29+
form = [userid: @username, password: @password, __VIEWSTATE: session.viewstate]
30+
31+
location =
32+
@api_url_login
33+
|> HTTPoison.post!({:form, form}, %{}, http_opts)
34+
|> get_redirect_path()
35+
36+
@api_url
37+
|> URI.merge(location)
38+
|> URI.to_string()
39+
|> HTTPoison.get!(%{}, http_opts)
40+
|> get_redirect_path()
41+
|> URI.parse()
42+
|> Map.get(:query)
43+
|> URI.query_decoder()
44+
|> Enum.into(%{})
45+
|> Map.get("token")
46+
end
47+
48+
# A browser session is identified by the cookie with key ASP.NET_SessionId
49+
# Additionally, the POST login form requires a field named __VIEWSTATE that is
50+
# embedded in the html (in an input tag). Therefore, a function
51+
# `get_browser_session` is defined to return %{:cookie, :viewstate}
52+
defp get_browser_session do
53+
response = HTTPoison.get!(@api_url_login)
54+
55+
viewstate =
56+
response.body
57+
|> Floki.find("input#__VIEWSTATE")
58+
|> Floki.attribute("value")
59+
|> List.first()
60+
61+
cookie =
62+
response.headers
63+
|> Enum.into(%{})
64+
|> Map.get("Set-Cookie")
65+
66+
%{:cookie => cookie, :viewstate => viewstate}
67+
end
68+
69+
# Extracts the location of a 302 redirect from a %HTTPoison.Response
70+
defp get_redirect_path(response) do
71+
response.headers
72+
|> Enum.into(%{})
73+
|> Map.get("Location")
74+
end
75+
end

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ defmodule Cadet.Mixfile do
4848
{:ecto_enum, "~> 1.0"},
4949
{:ex_json_schema, "~> 0.5"},
5050
{:ex_machina, "~> 2.1"},
51+
{:floki, "~> 0.20.0"},
5152
{:gettext, "~> 0.11"},
5253
{:guardian, "~> 1.0"},
5354
{:guardian_db, "~> 1.0"},

mix.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
2424
"exvcr": {:hex, :exvcr, "0.10.2", "a66a0fa86d03153e5c21e38b1320d10b537038d7bc7b10dcc1ab7f0343569822", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
2525
"file_system": {:hex, :file_system, "0.2.5", "a3060f063b116daf56c044c273f65202e36f75ec42e678dc10653056d3366054", [:mix], [], "hexpm"},
26+
"floki": {:hex, :floki, "0.20.3", "dfb3a71eb99938e330b4156433d55c6d0b188d936c9683d115a8540bac56e019", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
2627
"gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"},
2728
"git_hooks": {:hex, :git_hooks, "0.2.0", "3e437954b8dd8d63c723c25b6ae412766ad957a628d2c0aa3fd58cdf941c66c9", [:mix], [{:blankable, "~> 0.0.1", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.2", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm"},
2829
"guardian": {:hex, :guardian, "1.1.0", "36c1ea356a1bac02bc120c3f91f4f0259c5aa0ee92cee0efe8def5d7401f1921", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}, {:uuid, ">= 1.1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"},
2930
"guardian_db": {:hex, :guardian_db, "1.1.0", "45ab94206cce38f7443dc27de6dc52966ccbdeff65ca1b1f11a6d8f3daceb556", [:mix], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
3031
"hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
32+
"html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"},
3133
"httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
3234
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
3335
"jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
@@ -37,6 +39,7 @@
3739
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
3840
"mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"},
3941
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
42+
"mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"},
4043
"parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"},
4144
"pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.3", "6706a148809a29c306062862c803406e88f048277f6e85b68faf73291e820b84", [:mix], [], "hexpm"},
4245
"phoenix": {:hex, :phoenix, "1.3.3", "bafb5fa408d202e8d9f739e781bdb908446a2c1c1e00797c1158918ed55566a4", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},

test/cadet/public/updater_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Cadet.Public.UpdaterTest do
2+
@moduledoc """
3+
This test module uses pre-recoreded HTTP responses saved by ExVCR. This
4+
allows testing without actual external IVLE API calls.
5+
6+
In the case that you need to change the recorded responses, you will need
7+
to set the environment variables IVLE_KEY, GUEST_USERNAME, and GUEST_PASSWORD.
8+
Don't forget to delete the cassette files, otherwise ExVCR will not override
9+
the cassettes.
10+
11+
Token refers to the user's authentication token. Please see the IVLE API docs:
12+
https://wiki.nus.edu.sg/display/ivlelapi/Getting+Started
13+
To quickly obtain a token, simply supply a dummy url to a login call:
14+
https://ivle.nus.edu.sg/api/login/?apikey=YOUR_API_KEY&url=http://localhost
15+
then copy down the token from your browser's address bar.
16+
"""
17+
18+
use ExUnit.Case, async: false
19+
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
20+
21+
alias Cadet.Public.Updater
22+
23+
setup_all do
24+
HTTPoison.start()
25+
end
26+
27+
test "Get authentication token" do
28+
# a custom cassette is used, as body of 302 redirects expose the api key
29+
use_cassette "updater/get_token#1", custom: true do
30+
token = Updater.get_token()
31+
assert String.length(token) == 480
32+
end
33+
end
34+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
[
2+
{
3+
"request": {
4+
"body": "",
5+
"headers": [],
6+
"method": "get",
7+
"options": [],
8+
"request_body": "",
9+
"url": "~r/https://ivle.nus.edu.sg/api/login/\?apikey=.*/"
10+
},
11+
"response": {
12+
"binary": false,
13+
"body": "\r\n\r\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\r\n\r\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\r\n<head><title>\r\n\tLogin | IVLE\r\n</title><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\" /></head>\r\n<body>\r\n<!-- Header -->\r\n <table border=\"0\" style=\"background: #4F4E52;color: White; font-weight:bold\" width=\"100%\" cellpadding=\"8\" cellspacing=\"8\">\r\n <tr><td width=\"*\">Access to IVLE requested</td>\r\n <!-- <td width=\"20px\"><a onclick=\"parent.ivle_close();\" title=\"Close\">[X]</a></td> -->\r\n </tr>\r\n </table>\r\n <form name=\"frm\" method=\"post\" action=\"./?apikey=0&amp;url=_\" id=\"frm\">\r\n<input type=\"hidden\" name=\"__VIEWSTATE\" id=\"__VIEWSTATE\" value=\"/wEPDwULLTEzODMyMDQxNjEPFgIeE1ZhbGlkYXRlUmVxdWVzdE1vZGUCARYCAgEPZBYEAgEPD2QWAh4Gb25ibHVyBQ91c2VySWRUb1VwcGVyKClkAgkPD2QWBB4Lb25tb3VzZW92ZXIFNWRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCdsb2dpbmltZzEnKS5zcmM9b2ZmaW1nLnNyYzE7Hgpvbm1vdXNlb3V0BTRkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnbG9naW5pbWcxJykuc3JjPW9uaW1nLnNyYzE7ZBgBBR5fX0NvbnRyb2xzUmVxdWlyZVBvc3RCYWNrS2V5X18WAQUJbG9naW5pbWcxYTg4Q/LO3lNCB13iJpTeINmF1JQmGv61ni1TVgDIOII=\" />\r\n\r\n<input type=\"hidden\" name=\"__VIEWSTATEGENERATOR\" id=\"__VIEWSTATEGENERATOR\" value=\"B0AF59B8\" />\r\n <table border=\"0\">\r\n <tr valign=\"top\"><td>UserID</td><td>:</td><td> <input name=\"userid\" type=\"text\" maxlength=\"30\" id=\"userid\" onblur=\"userIdToUpper()\" />\r\n </td></tr>\r\n <tr valign=\"top\"><td>Password</td><td>:</td><td><input name=\"password\" type=\"password\" maxlength=\"100\" id=\"password\" />\r\n </td></tr>\r\n <tr valign=\"top\"><td colspan=\"3\">\r\n <input type=\"image\" name=\"loginimg1\" id=\"loginimg1\" onmouseover=\"document.getElementById(&#39;loginimg1&#39;).src=offimg.src1;\" onmouseout=\"document.getElementById(&#39;loginimg1&#39;).src=onimg.src1;\" src=\"/images/login.gif\" onclick=\"javascript:WebForm_DoPostBackWithOptions(new WebForm_PostBackOptions(&quot;loginimg1&quot;, &quot;&quot;, true, &quot;login&quot;, &quot;&quot;, false, false))\" border=\"0\" />\r\n <br />\r\n \r\n \r\n </td></tr>\r\n </table>\r\n </form>\r\n <script language=\"javascript\">\r\n var onimg = new Image();\r\n var offimg = new Image();\r\n onimg.src = \"/images/enter_new.gif\"; offimg.src = \"/images/enter_h_new.gif\";\r\n onimg.src1 = \"/images/login.gif\"; offimg.src1 = \"/images/login_h.gif\";\r\n\r\n function userIdToUpper() {\r\n var txt = document.getElementById(\"userid\");\r\n txt.value = txt.value.toUpperCase();\r\n } \r\n </script>\r\n</body>\r\n</html>\r\n",
14+
"headers": {
15+
"Cache-Control": "private",
16+
"Content-Type": "text/html; charset=utf-8",
17+
"Server": "Microsoft-IIS/8.5",
18+
"Request-Context": "appId=cid-v1:9bebd252-0be2-48b7-a1db-8e2b70524944",
19+
"Set-Cookie": "ASP.NET_SessionId=mfczcakb4tjhejtzq53h2r0h; path=/; HttpOnly",
20+
"X-AspNet-Version": "4.0.30319",
21+
"X-Powered-By": "ASP.NET",
22+
"Date": "Mon, 25 Jun 2018 07:57:27 GMT",
23+
"Content-Length": "2942"
24+
},
25+
"status_code": 200,
26+
"type": "ok"
27+
}
28+
},
29+
{
30+
"request": {
31+
"body": "~r/.*/",
32+
"headers": [],
33+
"method": "post",
34+
"options": {
35+
"cookie": "ASP.NET_SessionId=mfczcakb4tjhejtzq53h2r0h; path=/; HttpOnly",
36+
"follow_redirect": "false"
37+
},
38+
"request_body": "",
39+
"url": "~r/https://ivle.nus.edu.sg/api/login/.*/"
40+
},
41+
"response": {
42+
"binary": false,
43+
"body": "<html><head><title>Object moved</title></head><body>\r\n<h2>Object moved to <a href=\"/api/login/login_result.ashx?apikey=0;url=_&amp;r=0\">here</a>.</h2>\r\n</body></html>\r\n",
44+
"headers": {
45+
"Cache-Control": "private",
46+
"Content-Type": "text/html; charset=utf-8",
47+
"Location": "/api/login/login_result.ashx?apikey=0&url=_&r=0",
48+
"Server": "Microsoft-IIS/8.5",
49+
"Request-Context": "appId=cid-v1:9bebd252-0be2-48b7-a1db-8e2b70524944",
50+
"X-AspNet-Version": "4.0.30319",
51+
"X-Powered-By": "ASP.NET",
52+
"Date": "Mon, 25 Jun 2018 07:57:27 GMT",
53+
"Content-Length": "192"
54+
},
55+
"status_code": 302,
56+
"type": "ok"
57+
}
58+
},
59+
{
60+
"request": {
61+
"body": "",
62+
"headers": [],
63+
"method": "get",
64+
"options": {
65+
"cookie": "ASP.NET_SessionId=mfczcakb4tjhejtzq53h2r0h; path=/; HttpOnly",
66+
"follow_redirect": "false"
67+
},
68+
"request_body": "",
69+
"url": "~r/https://ivle.nus.edu.sg/api/login/login_result.*/"
70+
},
71+
"response": {
72+
"binary": false,
73+
"body": "<html><head><title>Object moved</title></head><body>\r\n<h2>Object moved to <a href=\"/api/login/_?token=2F2EAA5F146E57D90A682A3F656A94923051CEBABB1BBA8934C7A2EBC03B2DDEFC9919B4F1DB5406949894A3E07332E5DF9391345449D593C67D7888C1FC0BA8F6D2BF74198B0E8C0376CF222892BD3CD324702BC7D80E9A8D7D66A0E01307D9E0F934FBABE12480B809932670F598D9BCD7054D25579A9EEDCA93F26F4FDAFF5233A0495F0FB17A918CA246E61851EC1D383758401F44C4310A188F7816D675582F3B3478A6AADF0497393BE9222CCFD343A7C372736976438178162E2AB1CF8278F64DBD0FD83AFA29B07238CB826FC28A0FFEBEF35B08EF764B9370CC259590575589CC7319E8A00A04070928E70D\">here</a>.</h2>\r\n</body></html>\r\n",
74+
"headers": {
75+
"Cache-Control": "private",
76+
"Content-Type": "text/html; charset=utf-8",
77+
"Location": "/api/login/_?token=2F2EAA5F146E57D90A682A3F656A94923051CEBABB1BBA8934C7A2EBC03B2DDEFC9919B4F1DB5406949894A3E07332E5DF9391345449D593C67D7888C1FC0BA8F6D2BF74198B0E8C0376CF222892BD3CD324702BC7D80E9A8D7D66A0E01307D9E0F934FBABE12480B809932670F598D9BCD7054D25579A9EEDCA93F26F4FDAFF5233A0495F0FB17A918CA246E61851EC1D383758401F44C4310A188F7816D675582F3B3478A6AADF0497393BE9222CCFD343A7C372736976438178162E2AB1CF8278F64DBD0FD83AFA29B07238CB826FC28A0FFEBEF35B08EF764B9370CC259590575589CC7319E8A00A04070928E70D",
78+
"Server": "Microsoft-IIS/8.5",
79+
"Request-Context": "appId=cid-v1:9bebd252-0be2-48b7-a1db-8e2b70524944",
80+
"X-AspNet-Version": "4.0.30319",
81+
"X-Powered-By": "ASP.NET",
82+
"Date": "Mon, 25 Jun 2018 07:57:27 GMT",
83+
"Content-Length": "616"
84+
},
85+
"status_code": 302,
86+
"type": "ok"
87+
}
88+
}
89+
]

0 commit comments

Comments
 (0)