feat: allow multiple oauth connections per account

This commit is contained in:
MiniDigger | Martin 2023-12-16 18:16:04 +01:00
parent 89386deeac
commit 9155cd80c9
12 changed files with 112 additions and 46 deletions

View File

@ -106,6 +106,7 @@ public class AuthController extends HangarComponent {
public SettingsResponse settings() {
final long userId = this.getHangarPrincipal().getUserId();
final List<SettingsResponse.Authenticator> authenticators = this.getAuthenticators(userId);
final List<OAuthCredential.OAuthConnection> oauth = this.getOAuthConnections(userId);
final UserCredentialTable backupCodeTable = this.credentialsService.getCredential(userId, CredentialType.BACKUP_CODES);
boolean hasBackupCodes = false;
@ -123,9 +124,6 @@ public class AuthController extends HangarComponent {
final VerificationCodeTable verificationCode = this.verificationService.getVerificationCode(userId, VerificationCodeTable.VerificationCodeType.EMAIL_VERIFICATION);
final boolean emailPending = verificationCode != null && !this.verificationService.expired(verificationCode);
final List<UserCredentialTable> oauthCredentials = this.credentialsService.getAllCredentials(userId, CredentialType.OAUTH);
final List<OAuthCredential> oauth = oauthCredentials.stream().map(c -> c.getCredential().get(OAuthCredential.class)).toList();
return new SettingsResponse(authenticators, oauth, hasBackupCodes, hasTotp, emailVerified, emailPending, hasPassword);
}
@ -142,6 +140,17 @@ public class AuthController extends HangarComponent {
return List.of();
}
private List<OAuthCredential.OAuthConnection> getOAuthConnections(final long userId) {
final UserCredentialTable credential = this.credentialsService.getCredential(userId, CredentialType.OAUTH);
if (credential != null) {
final OAuthCredential oAuthCredential = credential.getCredential().get(OAuthCredential.class);
if (oAuthCredential != null && oAuthCredential.connections() != null) {
return oAuthCredential.connections();
}
}
return List.of();
}
@Unlocked
@PostMapping("/account")
public void saveAccount(@RequestBody final AccountForm form) {

View File

@ -37,13 +37,11 @@ public class OAuthController extends HangarComponent {
private final OAuthService oAuthService;
private final TokenService tokenService;
private final AuthService authService;
private final CredentialsService credentialsService;
public OAuthController(final OAuthService oAuthService, final TokenService tokenService, final AuthService authService, final CredentialsService credentialsService) {
public OAuthController(final OAuthService oAuthService, final TokenService tokenService, final AuthService authService) {
this.oAuthService = oAuthService;
this.tokenService = tokenService;
this.authService = authService;
this.credentialsService = credentialsService;
}
@GetMapping("/{provider}/login")
@ -92,7 +90,7 @@ public class OAuthController extends HangarComponent {
}
case SETTINGS -> {
final long userId = Long.parseLong(decodedState.getSubject());
this.credentialsService.registerCredential(userId, new OAuthCredential(provider, userDetails.id(), userDetails.username()));
this.oAuthService.registerCredentials(provider, userId, userDetails);
this.redirect(returnUrl);
}
}
@ -114,7 +112,7 @@ public class OAuthController extends HangarComponent {
final boolean tos = form.tos();
final UserTable newUser = this.oAuthService.register(oauthProvider, oauthId, username, email, tos, oauthEmail.equals(email));
this.credentialsService.registerCredential(newUser.getUserId(), new OAuthCredential(oauthProvider, oauthId, oauthUsername));
this.oAuthService.registerCredentials(oauthProvider, newUser.getUserId(), new OAuthUserDetails(oauthId, oauthUsername, oauthEmail));
this.authService.setAalAndLogin(newUser, 2);
return new OAuthSignupResponse(!newUser.isEmailVerified());
}

View File

@ -10,9 +10,11 @@ import org.jdbi.v3.core.mapper.JoinRow;
import org.jdbi.v3.spring5.JdbiRepository;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.config.RegisterJoinRowMapper;
import org.jdbi.v3.sqlobject.customizer.Define;
import org.jdbi.v3.sqlobject.customizer.Timestamped;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;
import org.jetbrains.annotations.Nullable;
@JdbiRepository
@ -27,9 +29,6 @@ public interface UserCredentialDAO {
@SqlQuery("SELECT * FROM user_credentials where type = :type and user_id = :userId LIMIT 1")
UserCredentialTable getByType(@EnumByOrdinal CredentialType type, long userId);
@SqlQuery("SELECT * FROM user_credentials where type = :type and user_id = :userId")
List<UserCredentialTable> getAllByType(@EnumByOrdinal CredentialType type, long userId);
@Nullable
@SqlQuery("SELECT * FROM user_credentials where type = :type and credential ->> 'user_handle' = :userHandle")
UserCredentialTable getByUserHandle(@EnumByOrdinal CredentialType type, String userHandle);
@ -45,19 +44,21 @@ public interface UserCredentialDAO {
@SqlQuery("SELECT type FROM user_credentials WHERE user_id = :userId AND type != :password AND (type != :webAuthn OR (credential ->> 'credentials' IS NOT NULL AND jsonb_array_length(credential -> 'credentials') > 0))")
List<CredentialType> getAll(long userId, @EnumByOrdinal CredentialType password, @EnumByOrdinal CredentialType webAuthn);
@UseStringTemplateEngine
@RegisterConstructorMapper(value = UserCredentialTable.class, prefix = "uc")
@RegisterConstructorMapper(UserTable.class)
@RegisterJoinRowMapper({UserTable.class, UserCredentialTable.class})
@SqlQuery("SELECT u.*, uc.id uc_id, uc.credential uc_credential, uc.created_at uc_created_at, uc.updated_at uc_updated_at, uc.user_id uc_user_id, uc.type uc_type FROM user_credentials uc JOIN users u ON uc.user_id = u.id WHERE credential ->> 'provider' = :provider AND credential ->> 'id' = :id")
JoinRow getOAuthUser(String provider, String id);
@SqlUpdate("DELETE FROM user_credentials WHERE type = :type and user_id = :userId and credential ->> 'provider' = :provider and credential ->> 'id' = :id")
void removeOAuth(long userId, @EnumByOrdinal CredentialType type, final String provider, final String id);
@Timestamped
@SqlUpdate("UPDATE user_credentials set credential = :credential, updated_at = :now WHERE type = :type and user_id = :userId and credential ->> 'provider' = :provider and credential ->> 'id' = :id")
void updateOAuth(long userId, JSONB credential, @EnumByOrdinal CredentialType type, final String provider, final String id);
@SqlQuery("SELECT count(*) FROM user_credentials where type = :type and user_id = :userId ")
long countByType(long userId, @EnumByOrdinal CredentialType type);
@SqlQuery("""
SELECT u.*,
uc.id uc_id,
uc.credential uc_credential,
uc.created_at uc_created_at,
uc.updated_at uc_updated_at,
uc.user_id uc_user_id,
uc.type uc_type
FROM user_credentials uc
JOIN users u ON uc.user_id = u.id
WHERE uc.type = :oauth AND jsonb_path_exists(uc.credential, '$.connections[*] ? (@.provider == "<provider>") ? (@.id == "<id>")');
""")
JoinRow getOAuthUser(@Define String provider, @Define String id, @EnumByOrdinal CredentialType oauth);
}

View File

@ -1,6 +1,10 @@
package io.papermc.hangar.components.auth.model.credential;
public record OAuthCredential(String provider, String id, String name) implements Credential {
import java.util.List;
public record OAuthCredential(List<OAuthConnection> connections) implements Credential {
public record OAuthConnection(String provider, String id, String name) {}
@Override
public CredentialType type() {

View File

@ -3,7 +3,7 @@ package io.papermc.hangar.components.auth.model.dto;
import io.papermc.hangar.components.auth.model.credential.OAuthCredential;
import java.util.List;
public record SettingsResponse(List<Authenticator> authenticators, List<OAuthCredential> oAuthCredentials, boolean hasBackupCodes, boolean hasTotp, boolean emailConfirmed, boolean emailPending, boolean hasPassword) {
public record SettingsResponse(List<Authenticator> authenticators, List<OAuthCredential.OAuthConnection> oauthConnections, boolean hasBackupCodes, boolean hasTotp, boolean emailConfirmed, boolean emailPending, boolean hasPassword) {
public record Authenticator(String addedAt, String displayName, String id) {}
}

View File

@ -1,6 +1,7 @@
package io.papermc.hangar.components.auth.model.oauth;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
public final class OAuthProvider {
@ -15,18 +16,20 @@ public final class OAuthProvider {
private final String[] scopes;
private final Mode mode;
private final String wellKnown;
private final String unlinkLink;
private String authorizationEndpoint;
private String tokenEndpoint;
private String userInfoEndpoint;
public OAuthProvider(final String name, final String clientId, final String clientSecret, final String[] scopes, final Mode mode, final String wellKnown) {
public OAuthProvider(final String name, final String clientId, final String clientSecret, final String[] scopes, final Mode mode, final String wellKnown, String unlinkLink) {
this.name = name;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes = scopes;
this.mode = mode;
this.wellKnown = wellKnown;
this.unlinkLink = unlinkLink;
}
public String name() {
@ -53,6 +56,10 @@ public final class OAuthProvider {
return this.wellKnown;
}
public String unlinkLink() {
return this.unlinkLink;
}
public String authorizationEndpoint() {
return this.authorizationEndpoint;
}

View File

@ -51,10 +51,6 @@ public class CredentialsService extends HangarComponent {
return this.userCredentialDAO.getByType(type, userId);
}
public List<UserCredentialTable> getAllCredentials(final long userId, final CredentialType type) {
return this.userCredentialDAO.getAllByType(type, userId);
}
public @Nullable UserCredentialTable getCredentialByUserHandle(final ByteArray userHandle) {
return this.userCredentialDAO.getByUserHandle(CredentialType.WEBAUTHN, userHandle.getBase64());
}

View File

@ -44,7 +44,7 @@ public class OAuthService extends HangarComponent {
private final Map<String, OAuthProvider> providers = new HashMap<>();
public OAuthService(final RestClient restClient, final Algorithm algo, JWT jwt, final AuthService authService, final UserDAO userDAO, final VerificationService verificationService, final UserCredentialDAO userCredentialDAO, final CredentialsService credentialsService) {
public OAuthService(final RestClient restClient, final Algorithm algo, final JWT jwt, final AuthService authService, final UserDAO userDAO, final VerificationService verificationService, final UserCredentialDAO userCredentialDAO, final CredentialsService credentialsService) {
this.restClient = restClient;
this.algo = algo;
this.jwt = jwt;
@ -204,12 +204,11 @@ public class OAuthService extends HangarComponent {
public UserTable register(final String provider, final String id, final String username, final String email, final boolean tos, final boolean emailVerified) {
this.authService.validateNewUser(username, email, tos);
if (this.userCredentialDAO.getOAuthUser(provider, id) != null) {
if (this.userCredentialDAO.getOAuthUser(provider, id, CredentialType.OAUTH) != null) {
throw new HangarApiException("This " + provider + " account is already linked to a Hangar account");
}
final UserTable userTable = this.userDAO.create(UUID.randomUUID(), username, email, null, "en", List.of(), false, "light", emailVerified, new JSONB(Map.of()));
if (!emailVerified) {
this.verificationService.sendVerificationCode(userTable.getUserId(), userTable.getEmail(), userTable.getName());
}
@ -222,7 +221,7 @@ public class OAuthService extends HangarComponent {
}
public UserTable login(final String provider, final String id, final String name) {
final JoinRow result = this.userCredentialDAO.getOAuthUser(provider, id);
final JoinRow result = this.userCredentialDAO.getOAuthUser(provider, id, CredentialType.OAUTH);
if (result == null) {
return null;
}
@ -238,8 +237,16 @@ public class OAuthService extends HangarComponent {
return null;
}
if (!name.equals(oAuthCredential.name())) {
this.userCredentialDAO.updateOAuth(userTable.getUserId(), new JSONB(new OAuthCredential(provider, id, name)), CredentialType.OAUTH, provider, id);
for (final OAuthCredential.OAuthConnection connection : oAuthCredential.connections()) {
if (connection.provider().equals(provider) && connection.id().equals(id)) {
if (!connection.name().equals(name)) {
oAuthCredential.connections().remove(connection);
oAuthCredential.connections().add(new OAuthCredential.OAuthConnection(provider, id, name));
this.credentialsService.updateCredential(userTable.getUserId(), oAuthCredential);
} else {
break;
}
}
}
return userTable;
@ -253,14 +260,52 @@ public class OAuthService extends HangarComponent {
final long userId = this.getHangarPrincipal().getUserId();
final boolean hasPassword = this.credentialsService.getCredential(userId, CredentialType.PASSWORD) != null;
final long oauthCreds = this.userCredentialDAO.countByType(userId, CredentialType.OAUTH);
if (!hasPassword && oauthCreds == 1) {
final OAuthCredential oAuthCredential = this.getOAuthCredential(userId);
if (!hasPassword && oAuthCredential.connections().size() == 1) {
throw new HangarApiException("You can't remove your last oauth account without having a password set");
}
final boolean removedAny = oAuthCredential.connections().removeIf(c -> c.provider().equals(provider) && c.id().equals(id));
if (!removedAny) {
throw new HangarApiException("Unknown connection");
}
this.credentialsService.updateCredential(userId, oAuthCredential);
this.userCredentialDAO.removeOAuth(userId, CredentialType.OAUTH, provider, id);
return oAuthProvider.unlinkLink().replace(":id", oAuthProvider.clientId());
}
// TODO get from provider
return "https://github.com/settings/connections/applications/" + oAuthProvider.clientId();
public void registerCredentials(final String provider, final long userId, final OAuthUserDetails userDetails) {
final OAuthCredential.OAuthConnection connection = new OAuthCredential.OAuthConnection(provider, userDetails.id(), userDetails.username());
final OAuthCredential oAuthCredential;
try {
// check if we already have connections
oAuthCredential = this.getOAuthCredential(userId);
} catch (final HangarApiException e) {
// else just add
this.credentialsService.registerCredential(userId, new OAuthCredential(List.of(connection)));
return;
}
// check if this account already has that connection
oAuthCredential.connections().stream().filter((c -> c.provider().equals(provider) && c.id().equals(userDetails.id()))).findFirst().ifPresent(c -> {
throw new HangarApiException("This " + provider + " account is already linked to your Hangar account");
});
// check if another account already has that connection
if (this.userCredentialDAO.getOAuthUser(provider, userDetails.id(), CredentialType.OAUTH) != null) {
throw new HangarApiException("This " + provider + " account is already linked to another Hangar account");
}
// add
oAuthCredential.connections().add(connection);
this.credentialsService.updateCredential(userId, oAuthCredential);
}
private OAuthCredential getOAuthCredential(final long userId) {
final UserCredentialTable oauth = this.credentialsService.getCredential(userId, CredentialType.OAUTH);
if (oauth == null) {
throw new HangarApiException("You don't have any oauth accounts linked");
}
final OAuthCredential oAuthCredential = oauth.getCredential().get(OAuthCredential.class);
if (oAuthCredential == null) {
throw new HangarApiException("You don't have any oauth accounts linked");
}
return oAuthCredential;
}
}

View File

@ -194,6 +194,7 @@ hangar:
client-secret: "d0cb6980a7c647b95cd30f8a2d6ac98b79cd67ac"
scopes:
- "user:email"
unlink-link: "https://github.com/settings/connections/applications/:id"
- name: "microsoft"
mode: "oidc"
client-id: "93147315-cd13-411a-8068-90e912c6e242"
@ -203,6 +204,7 @@ hangar:
- "email"
- "profile"
well-known: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"
unlink-link: "https://account.live.com/consent/Manage"
- name: "google"
mode: "oidc"
client-id: "731781739823-2gdq4irf8o50ktsctn358j5bd1an5r0k.apps.googleusercontent.com"
@ -212,6 +214,7 @@ hangar:
- "email"
- "profile"
well-known: "https://accounts.google.com/.well-known/openid-configuration"
unlink-link: "https://myaccount.google.com/connections"
jobs:
check-interval: 5s

View File

@ -57,6 +57,7 @@ stringData:
client-secret: "{{ .Values.backend.config.githubClientSecret }}"
scopes:
- "user:email"
unlink-link: "https://github.com/settings/connections/applications/:id"
- name: "microsoft"
mode: "oidc"
client-id: "{{ .Values.backend.config.microsoftClientId }}"
@ -66,6 +67,7 @@ stringData:
- "email"
- "profile"
well-known: "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"
unlink-link: "https://account.live.com/consent/Manage"
- name: "google"
mode: "oidc"
client-id: "{{ .Values.backend.config.googleClientId }}"
@ -75,6 +77,7 @@ stringData:
- "email"
- "profile"
well-known: "https://accounts.google.com/.well-known/openid-configuration"
unlink-link: "https://myaccount.google.com/connections"
storage:
plugin-upload-dir: "/hangar/uploads"

View File

@ -19,7 +19,7 @@ import PrettyTime from "~/components/design/PrettyTime.vue";
import { useBackendData } from "~/store/backendData";
import IconMdiGitHub from "~icons/mdi/github";
const props = defineProps<{
defineProps<{
settings?: AuthSettings;
}>();
const emit = defineEmits<{
@ -350,11 +350,11 @@ function closeUnlinkModal() {
</div>
<div class="flex gap-2 mt-2">
<Button
v-for="credential in settings?.oAuthCredentials"
v-for="credential in settings?.oauthConnections"
:key="credential.provider + credential.id"
:disabled="!settings?.hasPassword && settings?.oAuthCredentials.length === 1"
:disabled="!settings?.hasPassword && settings?.oauthConnections.length === 1"
:title="
!settings?.hasPassword && settings?.oAuthCredentials.length === 1
!settings?.hasPassword && settings?.oauthConnections.length === 1
? 'You can\'t unlink your last oauth credential if you don\'t have a password set'
: undefined
"

View File

@ -87,7 +87,7 @@ declare module "hangar-internal" {
interface AuthSettings {
authenticators: { addedAt: string; displayName: string; id: string }[];
oAuthCredentials: { id: string; name: string; provider: string }[];
oauthConnections: { id: string; name: string; provider: string }[];
hasBackupCodes: boolean;
hasTotp: boolean;
emailConfirmed: boolean;