mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-02-17 15:01:42 +08:00
feat: allow multiple oauth connections per account
This commit is contained in:
parent
89386deeac
commit
9155cd80c9
@ -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) {
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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) {}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
"
|
||||
|
2
frontend/src/types/internal/users.d.ts
vendored
2
frontend/src/types/internal/users.d.ts
vendored
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user