Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@
# SALESFORCE_CLIENT_ID=3MVG9_ghE
# SALESFORCE_CLIENT_SECRET=703777B

# Set this up to handle Google OAuth credentials (ex: GoogleSheets)
# GOOGLE_CLIENT_ID=660274980707
# GOOGLE_CLIENT_SECRET=GOCSPX-ua
# Set GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET to enable GitHub SSO sign-in
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=

# Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable Google SSO sign-in
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=

# Choose an admin email address and configure a mailer. If you don't specify
# mailer details the local test adaptor will be used and mail previews can be
Expand Down
122 changes: 121 additions & 1 deletion lib/lightning/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule Lightning.Accounts do
alias Lightning.Accounts.Events
alias Lightning.Accounts.User
alias Lightning.Accounts.UserBackupCode
alias Lightning.Accounts.UserIdentity
alias Lightning.Accounts.UserNotifier
alias Lightning.Accounts.UserToken
alias Lightning.Accounts.UserTOTP
Expand Down Expand Up @@ -172,7 +173,126 @@ defmodule Lightning.Accounts do
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user

cond do
is_nil(user) ->
User.valid_password?(user, password)
nil

is_nil(user.hashed_password) ->
User.valid_password?(user, password)
{:error, :sso_account}

User.valid_password?(user, password) ->
user

true ->
nil
end
end

@doc """
Looks up a user by their SSO provider identity.

Returns the `%User{}` if found, otherwise `nil`.
"""
def get_user_by_identity(provider, uid) do
from(u in User,
join: i in UserIdentity,
on: i.user_id == u.id,
where: i.provider == ^provider and i.uid == ^uid
)
|> Repo.one()
end

@doc """
Links an SSO provider identity to an existing user account.

Silently succeeds if the identity already exists (on_conflict: :nothing).
"""
def link_user_identity(%User{id: user_id}, provider, uid) do
%UserIdentity{}
|> UserIdentity.changeset(%{user_id: user_id, provider: provider, uid: uid})
|> Repo.insert(on_conflict: :nothing, conflict_target: [:provider, :uid])
end

@doc """
Returns the SSO identities linked to a user, ordered by provider name.
"""
def list_user_identities(%User{id: user_id}) do
from(i in UserIdentity,
where: i.user_id == ^user_id,
order_by: [asc: i.provider]
)
|> Repo.all()
end

@doc """
Gets a user's identity for a given provider, or `nil` if not linked.
"""
def get_user_identity(%User{id: user_id}, provider) do
Repo.get_by(UserIdentity, user_id: user_id, provider: provider)
end

@doc """
Removes the SSO identity for the given user and provider.

Refuses to remove the last identity for an SSO-only user (no password set),
since that would lock them out. Such users can set a password by going
through the password reset flow first.

Returns:
* `{:ok, identity}` when the identity is removed
* `{:error, :not_linked}` when the user has no identity for the provider
* `{:error, :would_lock_out}` when removing would leave an SSO-only user
with no way to log in
"""
def unlink_user_identity(%User{} = user, provider) do
case get_user_identity(user, provider) do
nil ->
{:error, :not_linked}

%UserIdentity{} = identity ->
if can_remove_identity?(user, identity) do
Repo.delete(identity)
else
{:error, :would_lock_out}
end
end
end

defp can_remove_identity?(%User{hashed_password: hp}, _identity)
when is_binary(hp),
do: true

defp can_remove_identity?(%User{} = user, %UserIdentity{id: identity_id}) do
other_count =
from(i in UserIdentity,
where: i.user_id == ^user.id and i.id != ^identity_id,
select: count(i.id)
)
|> Repo.one()

other_count > 0
end

@doc """
Registers a brand-new user via SSO.

The user is created without a password and confirmed immediately.
A `user_registered` event is broadcast on success.
"""
def register_user_from_sso(attrs, provider, uid) do
attrs = Map.put(attrs, :sso_identity, %{provider: provider, uid: uid})

Repo.transact(fn ->
AccountHook.handle_register_user(attrs)
end)
|> tap(fn result ->
with {:ok, user} <- result do
Events.user_registered(user)
end
end)
end

@doc """
Expand Down
18 changes: 18 additions & 0 deletions lib/lightning/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ defmodule Lightning.Accounts.User do
has_many :backup_codes, Lightning.Accounts.UserBackupCode,
on_replace: :delete

has_many :user_identities, Lightning.Accounts.UserIdentity

timestamps()
end

Expand Down Expand Up @@ -337,6 +339,22 @@ defmodule Lightning.Accounts.User do
change(user, confirmed_at: now)
end

@doc """
A changeset for registering a user via SSO. No password is required;
the account is confirmed immediately at registration time.
"""
def sso_registration_changeset(user, attrs) do
user
|> cast(attrs, [:first_name, :last_name, :email])
|> validate_required([:first_name, :last_name, :email])
|> validate_email_format()
|> validate_email_exists()
|> put_change(
:confirmed_at,
DateTime.utc_now() |> DateTime.truncate(:second)
)
end

@spec remove_github_token_changeset(t()) :: Ecto.Changeset.t()
def remove_github_token_changeset(user) do
change(user, github_oauth_token: nil)
Expand Down
25 changes: 25 additions & 0 deletions lib/lightning/accounts/user_identity.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Lightning.Accounts.UserIdentity do
@moduledoc """
Schema for tracking SSO provider identities linked to user accounts.

A user may have multiple identities (one per SSO provider). The combination
of provider and uid is globally unique.
"""
use Lightning.Schema

alias Lightning.Accounts.User

schema "user_identities" do
field :provider, :string
field :uid, :string
belongs_to :user, User
timestamps()
end

def changeset(identity, attrs) do
identity
|> cast(attrs, [:provider, :uid, :user_id])
|> validate_required([:provider, :uid, :user_id])
|> unique_constraint([:provider, :uid])
end
end
42 changes: 36 additions & 6 deletions lib/lightning/auth_providers/cache_warmer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ defmodule Lightning.AuthProviders.CacheWarmer do
# https://github.com/whitfin/cachex/issues/276
@dialyzer {:nowarn_function, init: 1}

# `GithubHandler.build/0` and `GoogleHandler.build/0` read their client
# credentials through `Lightning.Config.*_oauth/1`, which dispatches
# dynamically through an extension module. Dialyzer can't see that the
# binary branch is reachable, so it concludes `build/0` only ever returns
# `{:error, :not_configured}` and flags the `{:ok, _}` pattern here as
# unreachable. The runtime behaviour is fine.
@dialyzer {:nowarn_function, execute: 1}

@doc """
Returns the interval for this warmer.
"""
Expand All @@ -20,12 +28,34 @@ defmodule Lightning.AuthProviders.CacheWarmer do
Executes this cache warmer with a connection.
"""
def execute(_state) do
with %AuthProviders.AuthConfig{name: name} = config <-
AuthProviders.get_existing() || :ignore,
{:ok, handler} <- AuthProviders.Handler.from_model(config) do
{:ok, [{name, handler}]}
else
_error -> :ignore
db_entries =
try do
with %AuthProviders.AuthConfig{name: name} = config <-
AuthProviders.get_existing() || :not_found,
{:ok, handler} <- AuthProviders.Handler.from_model(config) do
[{name, handler}]
else
_ -> []
end
rescue
_ -> []
end

github_entries =
case Lightning.AuthProviders.GithubHandler.build() do
{:ok, handler} -> [{handler.name, handler}]
_ -> []
end

google_entries =
case Lightning.AuthProviders.GoogleHandler.build() do
{:ok, handler} -> [{handler.name, handler}]
_ -> []
end

case db_entries ++ github_entries ++ google_entries do
[] -> :ignore
entries -> {:ok, entries}
end
end
end
42 changes: 42 additions & 0 deletions lib/lightning/auth_providers/github_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Lightning.AuthProviders.GithubHandler do
@moduledoc """
Builds a Handler for GitHub OAuth2 SSO login from environment configuration.

Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET to enable GitHub login.
"""

alias Lightning.AuthProviders.Handler
alias Lightning.AuthProviders.WellKnown

@name "github"
@authorization_endpoint "https://github.com/login/oauth/authorize"
@token_endpoint "https://github.com/login/oauth/access_token"
@userinfo_endpoint "https://api.github.com/user"

def handler_name, do: @name

@spec build() :: {:ok, Handler.t()} | {:error, :not_configured}
def build do
client_id = Lightning.Config.github_oauth(:client_id)
client_secret = Lightning.Config.github_oauth(:client_secret)
redirect_uri = Lightning.Config.github_oauth(:redirect_uri)

if client_id && client_secret && redirect_uri do
wellknown = %WellKnown{
authorization_endpoint: @authorization_endpoint,
token_endpoint: @token_endpoint,
userinfo_endpoint: @userinfo_endpoint
}

Handler.new(@name,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
wellknown: wellknown,
scope: "read:user user:email"
)
else
{:error, :not_configured}
end
end
end
42 changes: 42 additions & 0 deletions lib/lightning/auth_providers/google_handler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Lightning.AuthProviders.GoogleHandler do
@moduledoc """
Builds a Handler for Google OAuth2 SSO login from environment configuration.

Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to enable Google login.
"""

alias Lightning.AuthProviders.Handler
alias Lightning.AuthProviders.WellKnown

@name "google"
@authorization_endpoint "https://accounts.google.com/o/oauth2/v2/auth"
@token_endpoint "https://oauth2.googleapis.com/token"
@userinfo_endpoint "https://openidconnect.googleapis.com/v1/userinfo"

def handler_name, do: @name

@spec build() :: {:ok, Handler.t()} | {:error, :not_configured}
def build do
client_id = Lightning.Config.google_oauth(:client_id)
client_secret = Lightning.Config.google_oauth(:client_secret)
redirect_uri = Lightning.Config.google_oauth(:redirect_uri)

if client_id && client_secret && redirect_uri do
wellknown = %WellKnown{
authorization_endpoint: @authorization_endpoint,
token_endpoint: @token_endpoint,
userinfo_endpoint: @userinfo_endpoint
}

Handler.new(@name,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
wellknown: wellknown,
scope: "openid email profile"
)
else
{:error, :not_configured}
end
end
end
Loading
Loading