Current section
Files
Jump to
Current section
Files
lib/ttlock_client/oauth.ex
defmodule TTlockClient.OAuth do
@moduledoc """
TTLock OAuth2 client for authentication and token management.
This module handles:
- Getting access tokens using Resource Owner Password Credentials
- Refreshing expired access tokens
- Password hashing (MD5) for TTLock API requirements
## Usage
# Get access token
{:ok, response} = TTlockClient.OAuth.get_access_token(
client_id: "your_client_id",
client_secret: "your_client_secret",
username: "+8618966498228",
password: "your_password"
)
# Refresh token
{:ok, response} = TTlockClient.OAuth.refresh_token(
client_id: "your_client_id",
client_secret: "your_client_secret",
refresh_token: "existing_refresh_token"
)
"""
@base_url "https://euapi.ttlock.com"
@token_endpoint "/oauth2/token"
@type oauth_config :: [
client_id: String.t(),
client_secret: String.t(),
username: String.t(),
password: String.t()
]
@type refresh_config :: [
client_id: String.t(),
client_secret: String.t(),
refresh_token: String.t()
]
@type token_response :: %{
access_token: String.t(),
uid: integer(),
refresh_token: String.t(),
expires_in: integer()
}
@type refresh_response :: %{
access_token: String.t(),
refresh_token: String.t(),
expires_in: integer()
}
@doc """
Get an access token using TTLock app account credentials.
## Parameters
- `client_id`: Application clientId from TTLock Create Application page
- `client_secret`: Application clientSecret from TTLock Create Application page
- `username`: TTLock app account username (or prefixed username from User Register API)
- `password`: Plain text password (will be MD5 hashed automatically)
## Returns
- `{:ok, token_response}` on success
- `{:error, reason}` on failure
## Example
{:ok, %{
"access_token" => "39caac89b0b51c980aa61ad4264b693b",
"uid" => 2340,
"refresh_token" => "1bd2a21a7df889630f444364813738d7",
"expires_in" => 7776000
}} = TTlockClient.OAuth.get_access_token(
client_id: "your_client_id",
client_secret: "your_client_secret",
username: "+8618966498228",
password: "your_password"
)
"""
@spec get_access_token(oauth_config()) :: {:ok, token_response()} | {:error, term()}
def get_access_token(opts \\ []) do
client_id = get_config_value(opts, :client_id)
client_secret = get_config_value(opts, :client_secret)
username = get_config_value(opts, :username)
password = get_config_value(opts, :password)
hashed_password = hash_password(password)
params = [
{"clientId", client_id},
{"clientSecret", client_secret},
{"username", username},
{"password", hashed_password}
]
make_token_request(params)
end
@doc """
Refresh an expired access token using a refresh token.
## Parameters
- `client_id`: Application clientId
- `client_secret`: Application clientSecret
- `refresh_token`: Refresh token obtained from previous get_access_token call
## Returns
- `{:ok, refresh_response}` on success
- `{:error, reason}` on failure
## Example
{:ok, %{
"access_token" => "39caac89b0b51c980aa61ad4264b693b",
"refresh_token" => "1bd2a21a7df889630f444364813738d7",
"expires_in" => 7776000
}} = TTlockClient.OAuth.refresh_token(
client_id: "your_client_id",
client_secret: "your_client_secret",
refresh_token: "existing_refresh_token"
)
"""
@spec refresh_token(refresh_config()) :: {:ok, refresh_response()} | {:error, term()}
def refresh_token(opts \\ []) do
client_id = get_config_value(opts, :client_id)
client_secret = get_config_value(opts, :client_secret)
refresh_token = Keyword.fetch!(opts, :refresh_token)
params = [
{"clientId", client_id},
{"clientSecret", client_secret},
{"grant_type", "refresh_token"},
{"refresh_token", refresh_token}
]
make_token_request(params)
end
@doc """
Hash a password using MD5 as required by TTLock API.
Returns a 32-character lowercase MD5 hash.
## Example
iex> TTlockClient.OAuth.hash_password("123456")
"e10adc3949ba59abbe56e057f20f883e"
"""
@spec hash_password(String.t()) :: String.t()
def hash_password(password) when is_binary(password) do
:crypto.hash(:md5, password)
|> Base.encode16(case: :lower)
end
# Private functions
@doc """
Get a valid access token, refreshing if necessary.
This function first checks for a stored access token. If none exists or if it's expired,
it will attempt to refresh using a stored refresh token. If refresh fails or no refresh
token exists, it will return an error indicating new authentication is needed.
## Parameters
- `client_id`: Application clientId (optional if configured in environment)
- `client_secret`: Application clientSecret (optional if configured in environment)
## Returns
- `{:ok, access_token}` on success
- `{:error, :authentication_required}` if new authentication is needed
- `{:error, reason}` on other failures
## Example
{:ok, access_token} = TTlockClient.OAuth.get_valid_access_token(
client_id: "your_client_id",
client_secret: "your_client_secret"
)
"""
@spec get_valid_access_token(Keyword.t()) :: {:ok, String.t()} | {:error, term()}
def get_valid_access_token(opts \\ []) do
client_id = get_config_value(opts, :client_id)
client_secret = get_config_value(opts, :client_secret)
case get_stored_token() do
{:ok, token, expires_at} ->
if token_valid?(expires_at) do
{:ok, token}
else
refresh_stored_token(client_id, client_secret)
end
:error ->
case get_stored_refresh_token() do
{:ok, refresh_token} ->
refresh_stored_token(client_id, client_secret, refresh_token)
:error ->
{:error, :authentication_required}
end
end
end
@doc """
Store access token and refresh token for future use.
## Parameters
- `access_token`: Access token to store
- `refresh_token`: Refresh token to store
- `expires_in`: Token lifetime in seconds
## Returns
- `:ok` on success
"""
@spec store_tokens(String.t(), String.t(), integer()) :: :ok
def store_tokens(access_token, refresh_token, expires_in) do
# 5 min buffer
expires_at = System.system_time(:second) + expires_in - 300
Application.put_env(:ex_ttlock, :access_token, access_token)
Application.put_env(:ex_ttlock, :refresh_token, refresh_token)
Application.put_env(:ex_ttlock, :token_expires_at, expires_at)
:ok
end
defp get_config_value(opts, key) do
case Keyword.get(opts, key) do
nil ->
env_key =
case key do
:client_id -> :ttlock_client_id
:client_secret -> :ttlock_client_secret
:access_token -> :ttlock_access_token
_ -> key
end
case Application.get_env(:ex_ttlock, env_key) do
nil -> raise ArgumentError, "#{key} is required either as option or in config"
value -> value
end
value ->
value
end
end
defp get_stored_token() do
case Application.get_env(:ex_ttlock, :access_token) do
nil ->
case Application.get_env(:ex_ttlock, :ttlock_access_token) do
nil ->
:error
token ->
expires_at = Application.get_env(:ex_ttlock, :token_expires_at, 0)
{:ok, token, expires_at}
end
token ->
expires_at = Application.get_env(:ex_ttlock, :token_expires_at, 0)
{:ok, token, expires_at}
end
end
defp get_stored_refresh_token() do
case Application.get_env(:ex_ttlock, :refresh_token) do
nil -> :error
token -> {:ok, token}
end
end
defp token_valid?(expires_at) do
System.system_time(:second) < expires_at
end
defp refresh_stored_token(client_id, client_secret, refresh_token \\ nil) do
refresh_token = refresh_token || Application.get_env(:ex_ttlock, :refresh_token)
case refresh_token do
nil ->
{:error, :authentication_required}
token ->
case refresh_token(
client_id: client_id,
client_secret: client_secret,
refresh_token: token
) do
{:ok,
%{
"access_token" => access_token,
"refresh_token" => new_refresh_token,
"expires_in" => expires_in
}} ->
store_tokens(access_token, new_refresh_token, expires_in)
{:ok, access_token}
{:error, _} ->
# Clear invalid tokens
Application.delete_env(:ex_ttlock, :access_token)
Application.delete_env(:ex_ttlock, :refresh_token)
Application.delete_env(:ex_ttlock, :token_expires_at)
{:error, :authentication_required}
end
end
end
defp make_token_request(params) do
url = @base_url <> @token_endpoint
body = URI.encode_query(params)
headers = [{"Content-Type", "application/x-www-form-urlencoded"}]
case HTTPoison.post(url, body, headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: response_body}} ->
case Jason.decode(response_body) do
{:ok, data} -> {:ok, data}
{:error, _} = error -> error
end
{:ok, %HTTPoison.Response{status_code: status_code, body: response_body}} ->
case Jason.decode(response_body) do
{:ok, error_data} -> {:error, {status_code, error_data}}
{:error, _} -> {:error, {status_code, response_body}}
end
{:error, %HTTPoison.Error{} = error} ->
{:error, error}
end
end
end