| 
 | 1 | +defmodule ExPwned.Passwords do  | 
 | 2 | + @moduledoc """  | 
 | 3 | + Module to interact with HIBP API to retrive breached passwords data.  | 
 | 4 | + """  | 
 | 5 | + | 
 | 6 | + @doc """  | 
 | 7 | + Returns true|false, or {:error, "reason for error, probably HTTP/Network related"}  | 
 | 8 | + """  | 
 | 9 | + def breached?(password) do  | 
 | 10 | + case password_breach_count(password) do  | 
 | 11 | + 0 -> false  | 
 | 12 | + x when x > 0 -> true  | 
 | 13 | + {:error, error} -> {:error, error}  | 
 | 14 | + unexpected -> unexpected  | 
 | 15 | + end  | 
 | 16 | + end  | 
 | 17 | + | 
 | 18 | + @doc """  | 
 | 19 | + Returns an integer representing the number of times this password has been  | 
 | 20 | + seen in a breach. Can be zero, representing no breaches.  | 
 | 21 | + """  | 
 | 22 | + def password_breach_count(password) do  | 
 | 23 | + {partial_hash, hash_suffix} =  | 
 | 24 | + password  | 
 | 25 | + |> hash_sha1()  | 
 | 26 | + |> String.split_at(5)  | 
 | 27 | + | 
 | 28 | + call_api(partial_hash, hash_suffix)  | 
 | 29 | + end  | 
 | 30 | + | 
 | 31 | + def call_api(partial_hash, hash_suffix) do  | 
 | 32 | + case HTTPoison.get("https://api.pwnedpasswords.com/range/" <> partial_hash) do  | 
 | 33 | + {:ok, %HTTPoison.Response{body: body, status_code: 200}} ->  | 
 | 34 | + handle_success(body, hash_suffix)  | 
 | 35 | + {:ok, %HTTPoison.Response{body: body, status_code: 429}} ->  | 
 | 36 | + {:error, body}  | 
 | 37 | + other ->  | 
 | 38 | + {:error, other}  | 
 | 39 | + end  | 
 | 40 | + end  | 
 | 41 | + | 
 | 42 | + def handle_success(body, hash_suffix) do  | 
 | 43 | + [_suffix, count] =  | 
 | 44 | + body  | 
 | 45 | + |> parse_body()  | 
 | 46 | + |> Enum.find(["", "0"], &matches_suffix(&1, hash_suffix)) # default: ["", "0"]  | 
 | 47 | + | 
 | 48 | + count |> String.to_integer()  | 
 | 49 | + end  | 
 | 50 | + | 
 | 51 | + def parse_body(body) do  | 
 | 52 | + body  | 
 | 53 | + |> String.split(~r/\r\n/, trim: true)  | 
 | 54 | + |> Enum.map(&String.split(&1, ":"))  | 
 | 55 | + end  | 
 | 56 | + | 
 | 57 | + defp matches_suffix([line_suffix, _count], search_suffix) do  | 
 | 58 | + line_suffix == search_suffix  | 
 | 59 | + end  | 
 | 60 | + | 
 | 61 | + defp hash_sha1(word) do  | 
 | 62 | + :crypto.hash(:sha, word) |> Base.encode16  | 
 | 63 | + end  | 
 | 64 | +end  | 
0 commit comments