fix oidc security bug
This commit is contained in:
parent
4f3d0c21a8
commit
293e85334f
3 changed files with 177 additions and 1 deletions
101
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
101
lib/accounts/user/validations/oidc_email_collision.ex
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||
@moduledoc """
|
||||
Validation that checks for email collisions during OIDC registration.
|
||||
|
||||
This validation prevents OIDC accounts from automatically taking over existing
|
||||
password-only accounts. Instead, it requires password verification.
|
||||
|
||||
## Scenarios:
|
||||
|
||||
1. **User exists with matching oidc_id**:
|
||||
- Allow (upsert will update the existing user)
|
||||
|
||||
2. **User exists with email but NO oidc_id (or empty string)**:
|
||||
- Raise PasswordVerificationRequired error
|
||||
- User must verify password before linking
|
||||
|
||||
3. **User exists with email AND different oidc_id**:
|
||||
- Raise PasswordVerificationRequired error
|
||||
- This prevents linking different OIDC providers to same account
|
||||
|
||||
4. **No user exists with this email**:
|
||||
- Allow (new user will be created)
|
||||
"""
|
||||
use Ash.Resource.Validation
|
||||
|
||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||
|
||||
@impl true
|
||||
def init(opts), do: {:ok, opts}
|
||||
|
||||
@impl true
|
||||
def validate(changeset, _opts, _context) do
|
||||
# Get the email and oidc_id from the changeset
|
||||
email = Ash.Changeset.get_attribute(changeset, :email)
|
||||
oidc_id = Ash.Changeset.get_attribute(changeset, :oidc_id)
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Only validate if we have both email and oidc_id (from OIDC registration)
|
||||
if email && oidc_id && user_info do
|
||||
check_email_collision(email, oidc_id, user_info)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_email_collision(email, new_oidc_id, user_info) do
|
||||
# Find existing user with this email
|
||||
case Mv.Accounts.User
|
||||
|> Ash.Query.filter(email == ^to_string(email))
|
||||
|> Ash.read_one() do
|
||||
{:ok, nil} ->
|
||||
# No user exists with this email - OK to create new user
|
||||
:ok
|
||||
|
||||
{:ok, existing_user} ->
|
||||
# User exists - check oidc_id
|
||||
handle_existing_user(existing_user, new_oidc_id, user_info)
|
||||
|
||||
{:error, error} ->
|
||||
# Database error
|
||||
{:error, field: :email, message: "Could not verify email uniqueness: #{inspect(error)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_existing_user(existing_user, new_oidc_id, user_info) do
|
||||
existing_oidc_id = existing_user.oidc_id
|
||||
|
||||
cond do
|
||||
# Case 1: Same oidc_id - this is an upsert, allow it
|
||||
existing_oidc_id == new_oidc_id ->
|
||||
:ok
|
||||
|
||||
# Case 2: No oidc_id set (nil or empty string) - password-only user
|
||||
is_nil(existing_oidc_id) or existing_oidc_id == "" ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: existing_user.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
|
||||
# Case 3: Different oidc_id - account conflict
|
||||
true ->
|
||||
{:error,
|
||||
PasswordVerificationRequired.exception(
|
||||
user_id: existing_user.id,
|
||||
oidc_user_info: user_info
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def atomic?(), do: false
|
||||
|
||||
@impl true
|
||||
def describe(_opts) do
|
||||
[
|
||||
message: "OIDC email collision detected",
|
||||
vars: []
|
||||
]
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue