Created
December 12, 2017 14:29
-
-
Save CharlesOkwuagwu/e2620a333959855b4afa16de1ed77e0b to your computer and use it in GitHub Desktop.
Raxx Web server + SSE + CORS + JWT -auth
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule RMAS.WebServer do | |
## setup | |
@moduledoc false | |
require Logger | |
use Raxx.Server | |
use Raxx.Static, "../dist" | |
## variables | |
{:ok, contents} = File.read("./dist/index.html") | |
@contents contents | |
@format [pretty: true, limit: :infinity, width: :infinity] | |
@jwt_key "some-really-secret-guid" | |
## handle requests | |
@doc " | |
- handle OPTIONS for CORS pre-flight | |
- handles /sse | |
- handle /api | |
- fallback for regular content" | |
def handle_head(r, s) do | |
Logger.info("[#{inspect self()}] #{inspect r, @format}") | |
try do | |
case r do | |
%{method: :TRACE} -> trace_request(r, s) | |
%{path: ["api" | _], method: :OPTIONS} -> preflight_request(r, s) | |
# %{path: ["api" | _]} -> api_request(r, s) | |
# %{path: ["sse" | _]} -> sse_request(r, s) | |
# _ -> general_request(r, s) | |
_ -> {[], s} | |
end | |
rescue | |
ex -> | |
Logger.error("[#{inspect self()}] #{inspect(ex)}\n#{Exception.format_stacktrace(System.stacktrace)}") | |
{[response(500) |> set_body(false)], s} | |
end | |
end | |
def handle_info(r, s) do | |
Logger.warn "#{inspect(r, @format)}" | |
r | |
end | |
def handle_data(data, {r, buffer, s}) do | |
r = %{r | body: buffer <> data} | |
case r do | |
%{path: ["api" | _]} -> api_request(r, s) | |
_ -> general_request(r, s) | |
end | |
end | |
defp trace_request(%{body: body}, state) do | |
outbound = [ | |
response(200) | |
|> set_body(body) | |
] | |
{outbound, state} | |
end | |
defp preflight_request(%{headers: headers}, state) do # OPTIONS -> handle pre-filght correctly | |
h = Enum.into(headers, %{}) | |
m = h["access-control-request-method"] | |
cors = [ | |
{"access-control-allow-credentials", "true"}, | |
{"access-control-allow-headers", "authorization,content-type"}, | |
{"access-control-allow-origin", h["origin"]} | |
] | |
cors = | |
if m in ["DELETE", "PUT", "PATCH"] do | |
cors ++ [{"access-control-allow-methods", m}] | |
else | |
cors | |
end | |
outbound = [ | |
response(204) | |
|> set_headers(cors) | |
|> set_body(false) | |
] | |
Logger.info("[#{inspect self()}] #{inspect outbound, @format}") | |
{outbound, state} | |
end | |
def api_request(req, state) do # api -> cors, request/response + json, check token | |
outbound = [ | |
proc_request(req) | |
] | |
{outbound, state} | |
end | |
defp sse_request(%{headers: headers}, state) do # sse | |
h = Enum.into(headers, %{}) | |
outbound = [ | |
response(200) | |
|> set_header("content-type", "text/event-stream") | |
|> set_header("access-control-allow-origin", h["origin"]) | |
|> set_header("access-control-allow-credentials", "true") | |
|> set_body(true) | |
] | |
{outbound, state} | |
end | |
defp general_request(_req, state) do # fallback -> request/response | |
outbound = [ | |
response(200) | |
|> set_header("content-type", "text/html; charset=utf-8") | |
|> set_body(@contents) | |
] | |
{outbound, state} | |
end | |
## Report Runner | |
defp proc_request(%{method: :POST, path: ["api", "report-runner"], body: data}) do | |
with {:ok, %{userid: userid, code: code, date: date}} <- Antidote.decode(data, keys: :atoms) do | |
RMAS.Server.run(userid, code, date, true) | |
cors_response(200) | |
else | |
e -> | |
Logger.warn("#{inspect(e, @format)}") | |
cors_response(400) | |
end | |
end | |
## Auth | |
defp proc_request(%{method: :POST, path: ["api", "auth", "login"], body: data}) do | |
with {:ok, m} <- Antidote.decode(data, keys: :atoms), | |
{:ok, 1, _, [u]} <- DB.Users.list(%{email: m.username}), | |
{:ok, true} <- verify_password(u.password, m.password) do | |
token = generate_token(u.user_id) | |
r = %{user: un_struct(u), token: token} | |
json = Antidote.encode!(r) | |
cors_response(json) | |
else | |
{:error, msg} -> | |
Logger.warn("#{inspect(msg, @format)}") | |
cors_response(:unauthorized) | |
_ -> cors_response(:unauthorized) | |
end | |
end | |
defp proc_request(%{method: :GET, path: ["api", "auth", "test"]}) do | |
cors_response(%{status: :OK}) | |
end | |
## Other | |
defp proc_request(%{method: :POST, path: ["api", "clearlogs"]}), do: run(204, DB.execute("truncate table systemlog")) | |
defp proc_request(%{method: :GET, path: ["api", "systemlog"]}), do: run(200, DB.execute("select id, l.user_id, isnull(u.name,'SYSTEM') runner, batch, report, report_dt, status, dt_start, dt_end, DATEDIFF(MS, dt_start, dt_end) as [ms_durn] from systemlog l left join users u on u.user_id = l.user_id order by batch desc, id")) | |
defp proc_request(%{method: :GET, path: ["api", entity]}), do: run(200, DB.execute("select * from #{entity}")) | |
defp proc_request(%{method: :GET, path: ["api", entity, id]}), do: run(200, DB.select(id, entity)) | |
## Users | |
defp proc_request(%{method: :GET, path: ["api", "users"]}), do: run(200, DB.Users.list()) | |
defp proc_request(%{method: :GET, path: ["api", "users", id]}), do: run(200, DB.Users.get(id)) | |
defp proc_request(%{method: :POST, path: ["api", "users"], body: data}) do | |
u = Antidote.decode!(data, keys: :atoms) | |
u = %{u | password: hash_password("password"), reset_required: true} | |
with {:ok, _, _, u} <- DB.Users.create(u) do | |
cors_response(un_struct(u), 201, [{"location", "/api/users/#{u.user_id}"}]) | |
else | |
e -> | |
Logger.warn("#{inspect(e, @format)}") | |
cors_response(400) | |
end | |
end | |
defp proc_request(%{method: :PUT, path: ["api", "users", id], body: data}) do | |
{:ok, 1, _, u} = DB.Users.get(id) | |
o = Antidote.decode!(data, keys: :atoms) | |
{password, reset} = | |
if o.password === "" or o.password === "0000000000" do | |
{u.password, o.reset_required} | |
else | |
{hash_password(o.password), false} | |
end | |
o = %{o | password: password, reset_required: reset} | |
run(200, DB.Users.update(o)) | |
end | |
defp proc_request(%{method: :DELETE, path: ["api", "users", id]}), do: run(204, DB.Users.delete(id)) | |
## fallback | |
defp proc_request(%{path: ["api"]}), do: cors_response("api-root") | |
defp proc_request(%{path: ["api" | _]}), do: cors_response(404) | |
## internal | |
## Security | |
def hash_password(password) do | |
# create a unique salt and salt string | |
salt = :crypto.strong_rand_bytes(16) | |
salt_string = Base.encode64(salt) | |
# create the hash passing in the password, the salt, the hmac, the number of iterations and the bytes to be returned | |
hash_bytes = pbkdf2(password, salt) | |
hash = Base.encode64(hash_bytes) | |
"#{salt_string}|#{hash}" | |
end | |
def verify_password(hashed_password, provided_password) do | |
# split the hash from the salt, like we stored it before | |
[stored_salt, stored_hash] = String.split(hashed_password, "|") | |
# get the byte arrays of the strings | |
salt_bytes = Base.decode64!(stored_salt) | |
# create the hash exactly how we did before, but with the stored salt | |
calc_hash_bytes = pbkdf2(provided_password, salt_bytes) | |
# string calc_hash = Convert.ToBase64String(calc_hash_bytes); | |
calc_hash = Base.encode64(calc_hash_bytes) | |
{:ok, calc_hash === stored_hash} | |
end | |
defp pbkdf2(password, salt), do: pbkdf2(password, salt, :sha256, 20000, 32, 1, [], 0) | |
defp pbkdf2(_password, _salt, _digest, _rounds, dklen, _block_index, acc, length) when length >= dklen do | |
key = acc |> Enum.reverse |> IO.iodata_to_binary | |
<<bin::binary-size(dklen), _::binary>> = key | |
bin | |
end | |
defp pbkdf2(password, salt, digest, rounds, dklen, block_index, acc, length) do | |
initial = :crypto.hmac(digest, password, <<salt::binary, block_index::integer-size(32)>>) | |
block = iterate(password, digest, rounds - 1, initial, initial) | |
pbkdf2(password, salt, digest, rounds, dklen, block_index + 1, | |
[block | acc], byte_size(block) + length) | |
end | |
defp iterate(_password, _digest, 0, _prev, acc), do: acc | |
defp iterate(password, digest, round, prev, acc) do | |
next = :crypto.hmac(digest, password, prev) | |
iterate(password, digest, round - 1, next, :crypto.exor(next, acc)) | |
end | |
defp generate_token(user_id) do | |
JsonWebToken.sign(%{user_id: user_id, jti: UUID.uuid4()}, %{key: @jwt_key}) | |
end | |
## utility | |
defp un_struct(l) when is_list(l), do: Enum.map(l, &un_struct/1) | |
defp un_struct(s = %_{}), do: Map.from_struct(s) | |
defp un_struct(s), do: s | |
defp cors_response(code) when is_atom(code), do: cors_response(nil, code) | |
defp cors_response(code) when is_integer(code), do: cors_response(nil, code) | |
defp cors_response(body, code \\ :ok, headers \\ []) | |
defp cors_response(body, code, headers), do: api_response(body, code, headers ++ [{"access-control-allow-origin", "*"}, {"access-control-allow-credentials", "true"}]) | |
defp api_response(body, code, headers) when is_list(headers) do | |
response(code) | |
|> set_header("content-type", "application/json; charset=utf-8") | |
|> set_headers(headers) | |
|> check_body(body) | |
end | |
defp sse_response(body, headers) do | |
response(200) | |
|> set_headers(headers ++ [{"access-control-allow-credentials", "true"}, {"content-type", "text/event-stream"}]) | |
|> set_body(body) | |
end | |
defp check_body(res, nil), do: res | |
defp check_body(res, b) when is_binary(b), do: set_body(res, b) | |
defp check_body(res, b), do: set_body(res, Antidote.encode!(b)) | |
defp run(code, {:ok, _count, _durn, nil}), do: cors_response(code) | |
defp run(code, {:ok, _count, _durn, value}), do: cors_response(un_struct(value), code) | |
defp run(_code, {:error, msg, _sql}), do: cors_response(%{error_msg: msg},400) | |
defp run(_code, {:error, x}), do: cors_response(%{error_msg: "#{inspect x}"},400) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment