diff --git a/packy-api/build.gradle b/packy-api/build.gradle index 7406937b..0dadc9fa 100644 --- a/packy-api/build.gradle +++ b/packy-api/build.gradle @@ -45,6 +45,10 @@ dependencies { // sentry implementation 'io.sentry:sentry-logback:6.26.0' + + // apple login + implementation 'org.bouncycastle:bcprov-jdk15on:1.69' + implementation 'org.bouncycastle:bcpkix-jdk14:1.72' } test { diff --git a/packy-api/src/main/java/com/dilly/auth/application/AppleService.java b/packy-api/src/main/java/com/dilly/auth/application/AppleService.java new file mode 100644 index 00000000..52e06d6b --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/application/AppleService.java @@ -0,0 +1,194 @@ +package com.dilly.auth.application; + +import java.io.Reader; +import java.io.StringReader; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import com.dilly.auth.AppleAccount; +import com.dilly.auth.model.AppleAccountInfo; +import com.dilly.auth.model.ApplePublicKey; +import com.dilly.auth.model.ApplePublicKey.Key; +import com.dilly.auth.model.AppleToken; +import com.dilly.global.exception.InternalServerException; +import com.dilly.global.response.ErrorCode; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppleService { + + @Value("${security.oauth2.provider.apple.audience-uri}") + private String appleAudienceUri; + @Value("${security.oauth2.provider.apple.token-uri}") + private String appleTokenUri; + @Value("${security.oauth2.provider.apple.key-uri}") + private String appleKeyUri; + @Value("${security.oauth2.provider.apple.revoke-uri}") + private String appleRevokeUri; + @Value("${security.oauth2.provider.apple.team-id}") + private String appleTeamId; + @Value("${security.oauth2.provider.apple.client-id}") + private String appleClientId; + @Value("${security.oauth2.provider.apple.key-id}") + private String appleKeyId; + @Value("${security.oauth2.provider.apple.private-key}") + private String applePrivateKey; + + public AppleToken getAppleToken(String providerAccessToken) { + try { + WebClient webClient = WebClient.builder() + .baseUrl(appleTokenUri) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .build(); + + MultiValueMap bodyData = new LinkedMultiValueMap<>(); + bodyData.add("client_id", appleClientId); + bodyData.add("client_secret", getAppleClientSecret()); + bodyData.add("code", providerAccessToken); + bodyData.add("grant_type", "authorization_code"); + + return webClient.post() + .body(BodyInserters.fromFormData(bodyData)) + .retrieve() + .bodyToMono(AppleToken.class) + .block(); + } catch (Exception e) { + throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR); + } + } + + public AppleAccountInfo getAppleAccountInfo(String idToken) { + try { + WebClient webClient = WebClient.builder() + .baseUrl(appleKeyUri) + .build(); + + ApplePublicKey applePublicKey = webClient.get() + .retrieve() + .bodyToMono(ApplePublicKey.class) + .block(); + + String headerOfIdToken = idToken.substring(0, idToken.indexOf(".")); + + Map header = new ObjectMapper().readValue( + new String(Base64.getUrlDecoder().decode(headerOfIdToken), StandardCharsets.UTF_8), + Map.class + ); + Key key = applePublicKey.getMatchedKeyBy(header.get("kid"), header.get("alg")) + .orElseThrow(() -> new NullPointerException("Apple ID 서버에서 공개키를 가져오지 못했습니다.")); + + byte[] nBytes = Base64.getUrlDecoder().decode(key.getN()); + byte[] eBytes = Base64.getUrlDecoder().decode(key.getE()); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance(key.getKty()); + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + Claims memberInfo = Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(idToken).getBody(); + + Map expectedMap = new HashMap<>(memberInfo); + + return new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .convertValue(expectedMap, AppleAccountInfo.class); + } catch (Exception e) { + throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR); + } + } + + private String getAppleClientSecret() { + Date expirationDate = Date.from( + LocalDateTime.now().plusDays(180).atZone(ZoneId.systemDefault()).toInstant() + ); + + try { + return Jwts.builder() + .setHeaderParam("kid", appleKeyId) + .setHeaderParam("alg", "ES256") + .setIssuer(appleTeamId) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(expirationDate) + .setAudience(appleAudienceUri) + .setSubject(appleClientId) + .signWith(getApplePrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } catch (Exception e) { + throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR); + } + } + + private PrivateKey getApplePrivateKey() { + try { + ClassPathResource resource = new ClassPathResource(applePrivateKey); + String privateKey = new String(resource.getInputStream().readAllBytes()); + Reader pemReader = new StringReader(privateKey); + PEMParser pemParser = new PEMParser(pemReader); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + PrivateKeyInfo object = (PrivateKeyInfo)pemParser.readObject(); + + return converter.getPrivateKey(object); + } catch (Exception e) { + throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR); + } + } + + public void revokeAppleAccount(AppleAccount appleAccount) { + try { + String appleRefreshToken = appleAccount.getRefreshToken(); + + WebClient webClient = WebClient.builder() + .baseUrl(appleRevokeUri) + .build(); + + MultiValueMap bodyData = new LinkedMultiValueMap<>(); + bodyData.add("client_id", appleClientId); + bodyData.add("client_secret", getAppleClientSecret()); + bodyData.add("token", appleRefreshToken); + bodyData.add("token_type_hint", "refresh_token"); + + webClient.post() + .body(BodyInserters.fromFormData(bodyData)) + .retrieve() + .bodyToMono(Void.class) + .block(); + + } catch (Exception e) { + throw new InternalServerException(ErrorCode.APPLE_SERVER_ERROR); + } + } +} diff --git a/packy-api/src/main/java/com/dilly/auth/application/AuthService.java b/packy-api/src/main/java/com/dilly/auth/application/AuthService.java index e6a61459..17b4d6f2 100644 --- a/packy-api/src/main/java/com/dilly/auth/application/AuthService.java +++ b/packy-api/src/main/java/com/dilly/auth/application/AuthService.java @@ -7,11 +7,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dilly.auth.AppleAccount; import com.dilly.auth.KakaoAccount; +import com.dilly.auth.domain.AppleAccountReader; +import com.dilly.auth.domain.AppleAccountWriter; import com.dilly.auth.domain.KakaoAccountReader; import com.dilly.auth.domain.KakaoAccountWriter; import com.dilly.auth.dto.request.SignupRequest; import com.dilly.auth.dto.response.SignInResponse; +import com.dilly.auth.model.AppleAccountInfo; +import com.dilly.auth.model.AppleToken; import com.dilly.auth.model.KakaoResource; import com.dilly.gift.ProfileImage; import com.dilly.global.exception.UnsupportedException; @@ -40,11 +45,14 @@ public class AuthService { private final JwtService jwtService; private final KakaoService kakaoService; + private final AppleService appleService; private final MemberReader memberReader; private final MemberWriter memberWriter; private final ProfileImageReader profileImageReader; private final KakaoAccountReader kakaoAccountReader; private final KakaoAccountWriter kakaoAccountWriter; + private final AppleAccountReader appleAccountReader; + private final AppleAccountWriter appleAccountWriter; private final JwtReader jwtReader; private final JwtWriter jwtWriter; @@ -65,6 +73,17 @@ public JwtResponse signUp(String providerAccessToken, SignupRequest signupReques case "apple" -> { provider = APPLE; + AppleToken appleToken = appleService.getAppleToken(providerAccessToken); + AppleAccountInfo appleAccountInfo = appleService.getAppleAccountInfo(appleToken.idToken()); + appleAccountReader.isAppleAccountPresent(appleAccountInfo.sub()); + + member = memberWriter.save(signupRequest.toEntity(provider, profileImage)); + appleAccountWriter.save(AppleAccount.builder() + .id(appleAccountInfo.sub()) + .member(member) + .refreshToken(appleToken.refreshToken()) + .build() + ); } default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE); @@ -81,6 +100,12 @@ public SignInResponse signIn(String provider, String providerAccessToken) { member = kakaoAccountReader.getMemberById(kakaoResource.getId()); } + case "apple" -> { + AppleToken appleToken = appleService.getAppleToken(providerAccessToken); + AppleAccountInfo appleAccountInfo = appleService.getAppleAccountInfo(appleToken.idToken()); + member = appleAccountReader.getMemberById(appleAccountInfo.sub()); + } + default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE); } @@ -110,7 +135,13 @@ public String withdraw() { kakaoService.unlinkKakaoAccount(kakaoAccount); kakaoAccountWriter.delete(kakaoAccount); } - + + case APPLE -> { + AppleAccount appleAccount = appleAccountReader.findByMember(member); + appleService.revokeAppleAccount(appleAccount); + appleAccountWriter.delete(appleAccount); + } + default -> throw new UnsupportedException(ErrorCode.UNSUPPORTED_LOGIN_TYPE); } diff --git a/packy-api/src/main/java/com/dilly/auth/application/KakaoService.java b/packy-api/src/main/java/com/dilly/auth/application/KakaoService.java index 6352916d..40667ead 100644 --- a/packy-api/src/main/java/com/dilly/auth/application/KakaoService.java +++ b/packy-api/src/main/java/com/dilly/auth/application/KakaoService.java @@ -12,7 +12,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserters; @@ -20,7 +19,6 @@ import org.springframework.web.reactive.function.client.WebClientException; import com.dilly.auth.KakaoAccount; -import com.dilly.auth.domain.KakaoAccountReader; import com.dilly.auth.model.KakaoResource; import com.dilly.global.exception.InternalServerException; import com.dilly.global.response.ErrorCode; @@ -32,28 +30,25 @@ @Service @RequiredArgsConstructor -@Transactional @Slf4j public class KakaoService { - private final KakaoAccountReader kakaoAccountReader; - @Value("${security.oauth2.provider.kakao.token-uri}") - private String KAKAO_TOKEN_URI; + private String kakaoTokenUri; @Value("${security.oauth2.provider.kakao.user-info-uri}") - private String KAKAO_USER_INFO_URI; + private String kakaoUserInfoUri; @Value("${security.oauth2.provider.kakao.unlink-uri}") - private String KAKAO_UNLINK_URI; + private String kakaoUnlinkUri; @Value("${security.oauth2.provider.kakao.client-id}") - private String KAKAO_CLIENT_ID; + private String kakaoClientId; @Value("${security.oauth2.provider.kakao.admin-key}") - private String KAKAO_ADMIN_KEY; - private String BEARER_PREFIX = "Bearer "; + private String kakaoAdminKey; public KakaoResource getKaKaoAccount(String kakaoAccessToken) { + String bearerPrefix = "Bearer "; WebClient webClient = WebClient.builder() - .baseUrl(KAKAO_USER_INFO_URI) - .defaultHeader(HttpHeaders.AUTHORIZATION, BEARER_PREFIX + kakaoAccessToken) + .baseUrl(kakaoUserInfoUri) + .defaultHeader(HttpHeaders.AUTHORIZATION, bearerPrefix + kakaoAccessToken) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) .build(); @@ -71,7 +66,7 @@ public String getKakaoAccessToken(String code) { String access_Token = ""; try { - URL url = new URL(KakaoService.this.KAKAO_TOKEN_URI); + URL url = new URL(KakaoService.this.kakaoTokenUri); HttpURLConnection conn = (HttpURLConnection)url.openConnection(); conn.setRequestMethod("POST"); @@ -79,10 +74,11 @@ public String getKakaoAccessToken(String code) { BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream())); StringBuilder sb = new StringBuilder(); - sb.append("grant_type=authorization_code"); - sb.append("&client_id=" + KAKAO_CLIENT_ID); - sb.append("&redirect_uri=http://127.0.0.1:9000/kakaocallback"); - sb.append("&code=" + code); + sb.append("grant_type=authorization_code") + .append("&client_id=") + .append(kakaoClientId) + .append("&redirect_uri=http://127.0.0.1:9000/kakaocallback") + .append("&code=" + code); bw.write(sb.toString()); bw.flush(); @@ -106,8 +102,8 @@ public String getKakaoAccessToken(String code) { public void unlinkKakaoAccount(KakaoAccount kakaoAccount) { WebClient webClient = WebClient.builder() - .baseUrl(KAKAO_UNLINK_URI) - .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + KAKAO_ADMIN_KEY) + .baseUrl(kakaoUnlinkUri) + .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoAdminKey) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) .build(); diff --git a/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountReader.java b/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountReader.java new file mode 100644 index 00000000..953e6c89 --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountReader.java @@ -0,0 +1,33 @@ +package com.dilly.auth.domain; + +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.dilly.auth.AppleAccount; +import com.dilly.auth.AppleAccountRepository; +import com.dilly.global.exception.alreadyexist.MemberAlreadyExistException; +import com.dilly.member.Member; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AppleAccountReader { + + private final AppleAccountRepository appleAccountRepository; + + public void isAppleAccountPresent(String sub) { + if (appleAccountRepository.findById(sub).isPresent()) { + throw new MemberAlreadyExistException(); + } + } + + public Optional getMemberById(String sub) { + return appleAccountRepository.findById(sub).map(AppleAccount::getMember); + } + + public AppleAccount findByMember(Member member) { + return appleAccountRepository.findByMember(member); + } +} diff --git a/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountWriter.java b/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountWriter.java new file mode 100644 index 00000000..812cac41 --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/domain/AppleAccountWriter.java @@ -0,0 +1,23 @@ +package com.dilly.auth.domain; + +import org.springframework.stereotype.Component; + +import com.dilly.auth.AppleAccount; +import com.dilly.auth.AppleAccountRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AppleAccountWriter { + + private final AppleAccountRepository appleAccountRepository; + + public void save(AppleAccount appleAccount) { + appleAccountRepository.save(appleAccount); + } + + public void delete(AppleAccount appleAccount) { + appleAccountRepository.delete(appleAccount); + } +} diff --git a/packy-api/src/main/java/com/dilly/auth/model/AppleAccountInfo.java b/packy-api/src/main/java/com/dilly/auth/model/AppleAccountInfo.java new file mode 100644 index 00000000..df04c386 --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/model/AppleAccountInfo.java @@ -0,0 +1,24 @@ +package com.dilly.auth.model; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonInclude(NON_NULL) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AppleAccountInfo( + String iss, + String exp, + String iat, + String sub, + String atHash, + String email, + Boolean emailVerified, + Boolean isEmail, + String authTime, + Boolean nonceSupported +) { + +} diff --git a/packy-api/src/main/java/com/dilly/auth/model/ApplePublicKey.java b/packy-api/src/main/java/com/dilly/auth/model/ApplePublicKey.java new file mode 100644 index 00000000..9c589d95 --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/model/ApplePublicKey.java @@ -0,0 +1,34 @@ +package com.dilly.auth.model; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + +import java.util.ArrayList; +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Getter; + +@JsonInclude(NON_NULL) +public class ApplePublicKey { + + ArrayList keys; + + @Getter + @JsonInclude(NON_NULL) + public static class Key { + + String kty; + String kid; + String use; + String alg; + String n; + String e; + } + + public Optional getMatchedKeyBy(String kid, String alg) { + return this.keys.stream() + .filter(key -> key.kid.equals(kid) && key.alg.equals(alg)) + .findFirst(); + } +} diff --git a/packy-api/src/main/java/com/dilly/auth/model/AppleToken.java b/packy-api/src/main/java/com/dilly/auth/model/AppleToken.java new file mode 100644 index 00000000..e8425c70 --- /dev/null +++ b/packy-api/src/main/java/com/dilly/auth/model/AppleToken.java @@ -0,0 +1,16 @@ +package com.dilly.auth.model; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonInclude(NON_NULL) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record AppleToken( + String accessToken, + String refreshToken, + String idToken +) { +} diff --git a/packy-api/src/main/java/com/dilly/global/response/ErrorCode.java b/packy-api/src/main/java/com/dilly/global/response/ErrorCode.java index 2889fa38..1e8e372d 100644 --- a/packy-api/src/main/java/com/dilly/global/response/ErrorCode.java +++ b/packy-api/src/main/java/com/dilly/global/response/ErrorCode.java @@ -19,6 +19,7 @@ public enum ErrorCode { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 오류가 발생했습니다."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP Method 요청입니다."), KAKAO_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "카카오 서버 연동에 오류가 발생했습니다."), + APPLE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "애플 서버 연동에 오류가 발생했습니다."), API_NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 API를 찾을 수 없습니다."), // Authorization diff --git a/packy-api/src/test/resources/application-test.yml b/packy-api/src/test/resources/application-test.yml index d9be6883..943cd19f 100644 --- a/packy-api/src/test/resources/application-test.yml +++ b/packy-api/src/test/resources/application-test.yml @@ -21,7 +21,7 @@ spring: flyway: enabled: false - + security: oauth2: provider: @@ -33,6 +33,10 @@ security: client-id: test admin-key: test apple: + audience-uri: test + token-uri: test + key-uri: test + revoke-uri: test team-id: test client-id: test key-id: test diff --git a/packy-domain/src/main/java/com/dilly/auth/AppleAccount.java b/packy-domain/src/main/java/com/dilly/auth/AppleAccount.java new file mode 100644 index 00000000..9eff2cf4 --- /dev/null +++ b/packy-domain/src/main/java/com/dilly/auth/AppleAccount.java @@ -0,0 +1,32 @@ +package com.dilly.auth; + +import com.dilly.global.BaseTimeEntity; +import com.dilly.member.Member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AppleAccount extends BaseTimeEntity { + + @Id + private String id; + + @Column(nullable = false) + private String refreshToken; + + @OneToOne + @JoinColumn + private Member member; +} diff --git a/packy-domain/src/main/java/com/dilly/auth/AppleAccountRepository.java b/packy-domain/src/main/java/com/dilly/auth/AppleAccountRepository.java new file mode 100644 index 00000000..0114d766 --- /dev/null +++ b/packy-domain/src/main/java/com/dilly/auth/AppleAccountRepository.java @@ -0,0 +1,10 @@ +package com.dilly.auth; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dilly.member.Member; + +public interface AppleAccountRepository extends JpaRepository { + + AppleAccount findByMember(Member member); +} diff --git a/packy-domain/src/main/resources/db/migration/V3__add_apple_account.sql b/packy-domain/src/main/resources/db/migration/V3__add_apple_account.sql new file mode 100644 index 00000000..f91c7d80 --- /dev/null +++ b/packy-domain/src/main/resources/db/migration/V3__add_apple_account.sql @@ -0,0 +1,13 @@ +create table apple_account +( + created_at datetime(6) null, + member_id bigint null, + updated_at datetime(6) null, + id varchar(255) not null + primary key, + refresh_token varchar(255) not null, + constraint UK_dgjlx80xq0hvlqlrolg0nxfbl + unique (member_id), + constraint FKjp63nbe4doslyu7tfmv57nb71 + foreign key (member_id) references member (id) +); diff --git a/packy-submodule b/packy-submodule index 7f1ed14d..699601e2 160000 --- a/packy-submodule +++ b/packy-submodule @@ -1 +1 @@ -Subproject commit 7f1ed14dac8435fabcbe4936a99d59cc517f113c +Subproject commit 699601e2030a0667f7a270bdaa236e87a4c86805