diff --git a/api/src/main/java/com/databasir/api/LoginController.java b/api/src/main/java/com/databasir/api/LoginController.java index bb319d5..414868d 100644 --- a/api/src/main/java/com/databasir/api/LoginController.java +++ b/api/src/main/java/com/databasir/api/LoginController.java @@ -53,4 +53,5 @@ public class LoginController { throw e; } } + } diff --git a/api/src/main/java/com/databasir/api/OAuth2LoginController.java b/api/src/main/java/com/databasir/api/OAuth2LoginController.java new file mode 100644 index 0000000..b5ba38f --- /dev/null +++ b/api/src/main/java/com/databasir/api/OAuth2LoginController.java @@ -0,0 +1,60 @@ +package com.databasir.api; + +import com.databasir.common.JsonData; +import com.databasir.core.domain.login.data.LoginKeyResponse; +import com.databasir.core.infrastructure.oauth2.OAuthAppService; +import com.databasir.core.infrastructure.oauth2.OAuthHandler; +import com.databasir.core.infrastructure.oauth2.data.OAuthAppResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.view.RedirectView; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; + +@Controller +@RequiredArgsConstructor +public class OAuth2LoginController { + + private final OAuthHandler oAuthHandler; + + private final OAuthAppService oAuthAppService; + + @GetMapping("/oauth2/authorization/{registrationId}") + public RedirectView authorization(@PathVariable String registrationId) { + String authorization = oAuthHandler.authorization(registrationId); + return new RedirectView(authorization); + } + + @GetMapping("/oauth2/login/{registrationId}") + public RedirectView callback(@PathVariable String registrationId, + @RequestParam Map params, + HttpServletResponse response) { + LoginKeyResponse loginKey = oAuthAppService.oauthCallback(registrationId, params); + // set cookie + Cookie accessTokenCookie = new Cookie("accessToken", loginKey.getAccessToken()); + accessTokenCookie.setPath("/"); + response.addCookie(accessTokenCookie); + + long epochSecond = loginKey.getAccessTokenExpireAt() + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + Cookie accessTokenExpireAtCookie = new Cookie("accessTokenExpireAt", epochSecond + ""); + accessTokenExpireAtCookie.setPath("/"); + response.addCookie(accessTokenExpireAtCookie); + return new RedirectView("/"); + } + + @GetMapping("/oauth2/apps") + public JsonData> listApps() { + return JsonData.ok(oAuthAppService.listAll()); + } + +} diff --git a/api/src/main/java/com/databasir/api/config/SecurityConfig.java b/api/src/main/java/com/databasir/api/config/SecurityConfig.java index 79d634d..55bd7e2 100644 --- a/api/src/main/java/com/databasir/api/config/SecurityConfig.java +++ b/api/src/main/java/com/databasir/api/config/SecurityConfig.java @@ -45,6 +45,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { .and() .authorizeRequests() .antMatchers("/login", Routes.Login.REFRESH_ACCESS_TOKEN).permitAll() + .antMatchers("/oauth2/apps", "/oauth2/failure", "/oauth2/authorization/*", "/oauth2/login/*").permitAll() .antMatchers("/", "/*.html", "/js/**", "/css/**", "/img/**", "/*.ico").permitAll() .anyRequest().authenticated() .and() diff --git a/api/src/main/resources/application-local.properties b/api/src/main/resources/application-local.properties index b3da7a4..60c4656 100644 --- a/api/src/main/resources/application-local.properties +++ b/api/src/main/resources/application-local.properties @@ -8,4 +8,4 @@ spring.jooq.sql-dialect=mysql spring.flyway.enabled=true spring.flyway.baseline-on-migrate=true -spring.flyway.locations=classpath:db/migration \ No newline at end of file +spring.flyway.locations=classpath:db/migration diff --git a/core/build.gradle b/core/build.gradle index b274cc2..58cd16d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -31,6 +31,9 @@ dependencies { implementation 'org.commonmark:commonmark:0.18.1' implementation 'org.freemarker:freemarker:2.3.31' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-jackson:2.9.0' + // test testImplementation "mysql:mysql-connector-java:${mysqlConnectorVersion}" } diff --git a/core/src/main/java/com/databasir/core/domain/user/service/UserService.java b/core/src/main/java/com/databasir/core/domain/user/service/UserService.java index 5cbff1e..a510cd4 100644 --- a/core/src/main/java/com/databasir/core/domain/user/service/UserService.java +++ b/core/src/main/java/com/databasir/core/domain/user/service/UserService.java @@ -65,14 +65,14 @@ public class UserService { } @Transactional - public void create(UserCreateRequest userCreateRequest) { + public Integer create(UserCreateRequest userCreateRequest) { userDao.selectByEmailOrUsername(userCreateRequest.getUsername()).ifPresent(data -> { throw DomainErrors.USERNAME_OR_EMAIL_DUPLICATE.exception(); }); String hashedPassword = bCryptPasswordEncoder.encode(userCreateRequest.getPassword()); UserPojo pojo = userPojoConverter.of(userCreateRequest, hashedPassword); try { - userDao.insertAndReturnId(pojo); + return userDao.insertAndReturnId(pojo); } catch (DuplicateKeyException e) { throw DomainErrors.USERNAME_OR_EMAIL_DUPLICATE.exception(); } diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/GithubOauthHandler.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/GithubOauthHandler.java new file mode 100644 index 0000000..c9654f8 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/GithubOauthHandler.java @@ -0,0 +1,71 @@ +package com.databasir.core.infrastructure.oauth2; + + +import com.databasir.core.infrastructure.remote.github.GithubRemoteService; +import com.databasir.dao.enums.OAuthAppType; +import com.databasir.dao.impl.OAuthAppDao; +import com.databasir.dao.tables.pojos.OauthAppPojo; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class GithubOauthHandler implements OAuthHandler { + + private final GithubRemoteService githubRemoteService; + + private final OAuthAppDao oAuthAppDao; + + @Override + public boolean support(String oauthAppType) { + return OAuthAppType.GITHUB.isSame(oauthAppType); + } + + @Override + public String authorization(String registrationId) { + OauthAppPojo app = oAuthAppDao.selectByRegistrationId(registrationId); + String authUrl = app.getAuthUrl(); + String clientId = app.getClientId(); + String authorizeUrl = authUrl + "/login/oauth/authorize"; + String url = UriComponentsBuilder.fromUriString(authorizeUrl) + .queryParam("client_id", clientId) + .queryParam("scope", "read:user user:email") + .encode() + .build() + .toUriString(); + return url; + } + + @Override + public OAuthProcessResult process(OAuthProcessContext context) { + OauthAppPojo authApp = oAuthAppDao.selectByRegistrationId(context.getRegistrationId()); + String clientId = authApp.getClientId(); + String clientSecret = authApp.getClientSecret(); + String baseUrl = authApp.getResourceUrl(); + + Map params = context.getCallbackParameters(); + String code = params.get("code"); + String accessToken = githubRemoteService.getToken(baseUrl, clientId, clientSecret, code) + .get("access_token") + .asText(); + String email = null; + for (JsonNode node : githubRemoteService.getEmail(baseUrl, accessToken)) { + if (node.get("primary").asBoolean()) { + email = node.get("email").asText(); + } + } + JsonNode profile = githubRemoteService.getProfile(baseUrl, accessToken); + String nickname = profile.get("name").asText(); + String avatar = profile.get("avatar_url").asText(); + OAuthProcessResult result = new OAuthProcessResult(); + result.setEmail(email); + result.setNickname(nickname); + result.setUsername(email); + result.setAvatar(avatar); + return result; + } +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthAppService.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthAppService.java new file mode 100644 index 0000000..3955e73 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthAppService.java @@ -0,0 +1,61 @@ +package com.databasir.core.infrastructure.oauth2; + +import com.databasir.core.domain.login.data.LoginKeyResponse; +import com.databasir.core.domain.login.service.LoginService; +import com.databasir.core.domain.user.data.UserCreateRequest; +import com.databasir.core.domain.user.service.UserService; +import com.databasir.core.infrastructure.oauth2.data.OAuthAppResponse; +import com.databasir.dao.impl.OAuthAppDao; +import com.databasir.dao.tables.pojos.OauthAppPojo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthAppService { + + private final List oAuthHandlers; + + private final OAuthAppDao oAuthAppDao; + + private final UserService userService; + + private final LoginService loginService; + + public LoginKeyResponse oauthCallback(String registrationId, Map params) { + + // match handler + OauthAppPojo app = oAuthAppDao.selectByRegistrationId(registrationId); + OAuthHandler oAuthHandler = oAuthHandlers.stream() + .filter(handler -> handler.support(app.getAppType())) + .findFirst() + .orElseThrow(); + + // process by handler + OAuthProcessContext context = OAuthProcessContext.builder() + .callbackParameters(params) + .registrationId(registrationId) + .build(); + OAuthProcessResult result = oAuthHandler.process(context); + + // create new user + UserCreateRequest user = new UserCreateRequest(); + user.setUsername(result.getUsername()); + user.setNickname(result.getNickname()); + user.setEmail(result.getEmail()); + user.setAvatar(result.getAvatar()); + user.setPassword(UUID.randomUUID().toString().substring(0, 6)); + Integer userId = userService.create(user); + + return loginService.generate(userId); + } + + public List listAll() { + return null; + } + +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthHandler.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthHandler.java new file mode 100644 index 0000000..e0973fb --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthHandler.java @@ -0,0 +1,10 @@ +package com.databasir.core.infrastructure.oauth2; + +public interface OAuthHandler { + + boolean support(String oauthAppType); + + String authorization(String registrationId); + + OAuthProcessResult process(OAuthProcessContext context); +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessContext.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessContext.java new file mode 100644 index 0000000..36e2de6 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessContext.java @@ -0,0 +1,22 @@ +package com.databasir.core.infrastructure.oauth2; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OAuthProcessContext { + + private String registrationId; + + @Builder.Default + private Map callbackParameters = new HashMap<>(); + +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessResult.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessResult.java new file mode 100644 index 0000000..550649c --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/OAuthProcessResult.java @@ -0,0 +1,16 @@ +package com.databasir.core.infrastructure.oauth2; + +import lombok.Data; + +@Data +public class OAuthProcessResult { + + private String email; + + private String username; + + private String nickname; + + private String avatar; + +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/oauth2/data/OAuthAppResponse.java b/core/src/main/java/com/databasir/core/infrastructure/oauth2/data/OAuthAppResponse.java new file mode 100644 index 0000000..a23f31a --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/oauth2/data/OAuthAppResponse.java @@ -0,0 +1,32 @@ +package com.databasir.core.infrastructure.oauth2.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OAuthAppResponse { + + private Integer id; + + private String name; + + private String icon; + + private String registrationId; + + private String clientId; + + private String clientSecret; + + private LocalDateTime updateAt; + + private LocalDateTime createAt; + +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/remote/ClientConfig.java b/core/src/main/java/com/databasir/core/infrastructure/remote/ClientConfig.java new file mode 100644 index 0000000..356cec3 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/remote/ClientConfig.java @@ -0,0 +1,34 @@ +package com.databasir.core.infrastructure.remote; + +import com.databasir.core.infrastructure.remote.github.GithubApiClient; +import com.databasir.core.infrastructure.remote.github.GithubOauthClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class ClientConfig { + + @Bean + public GithubApiClient githubApiClient() { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://api.github.com") + .addConverterFactory(JacksonConverterFactory.create()) + .build(); + return retrofit.create(GithubApiClient.class); + } + + @Bean + public GithubOauthClient githubOauthClient() { + Retrofit retrofit = new Retrofit.Builder() + .baseUrl("https://github.com") + .addConverterFactory(JacksonConverterFactory.create()) + .build(); + return retrofit.create(GithubOauthClient.class); + } +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubApiClient.java b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubApiClient.java new file mode 100644 index 0000000..b2a5f8c --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubApiClient.java @@ -0,0 +1,23 @@ +package com.databasir.core.infrastructure.remote.github; + +import com.fasterxml.jackson.databind.JsonNode; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.Headers; +import retrofit2.http.Url; + +public interface GithubApiClient { + + @GET + @Headers(value = { + "Accept: application/json" + }) + Call getEmail(@Url String url, @Header("Authorization") String token); + + @GET + @Headers(value = { + "Accept: application/json" + }) + Call getProfile(@Url String url, @Header("Authorization") String token); +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubOauthClient.java b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubOauthClient.java new file mode 100644 index 0000000..b22dee9 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubOauthClient.java @@ -0,0 +1,20 @@ +package com.databasir.core.infrastructure.remote.github; + +import com.fasterxml.jackson.databind.JsonNode; +import retrofit2.Call; +import retrofit2.http.Headers; +import retrofit2.http.POST; +import retrofit2.http.QueryMap; +import retrofit2.http.Url; + +import java.util.Map; + +public interface GithubOauthClient { + + @Headers(value = { + "Accept: application/json" + }) + @POST + Call getAccessToken(@Url String url, @QueryMap Map request); + +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubRemoteService.java b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubRemoteService.java new file mode 100644 index 0000000..9dcac8c --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/remote/github/GithubRemoteService.java @@ -0,0 +1,74 @@ +package com.databasir.core.infrastructure.remote.github; + +import com.databasir.common.SystemException; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import retrofit2.Call; +import retrofit2.Response; + +import java.io.IOException; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GithubRemoteService { + + private static final String TOKEN_PREFIX = "token "; + + private final GithubApiClient githubApiClient; + + private final GithubOauthClient githubOauthClient; + + public JsonNode getToken(String baseUrl, + String clientId, + String clientSecret, + String code) { + TokenRequest request = TokenRequest.builder() + .client_id(clientId) + .client_secret(clientSecret) + .code(code) + .build(); + String path = "/login/oauth/access_token"; + String url = baseUrl + path; + return execute(githubOauthClient.getAccessToken(url, request.toMap())); + } + + public JsonNode getEmail(String baseUrl, String token) { + String path; + if (baseUrl.contains("api.github.com")) { + path = "/user/emails"; + } else { + path = "/api/v3/user/emails"; + } + String url = baseUrl + path; + return execute(githubApiClient.getEmail(url, TOKEN_PREFIX + token)); + } + + public JsonNode getProfile(String baseUrl, String token) { + String path; + if (baseUrl.contains("api.github.com")) { + path = "/user"; + } else { + path = "/api/v3/user"; + } + String url = baseUrl + path; + return execute(githubApiClient.getProfile(url, TOKEN_PREFIX + token)); + } + + private T execute(Call call) { + try { + Response response = call.execute(); + if (!response.isSuccessful()) { + log.error("request error: " + call.request()); + throw new SystemException("Call Remote Error"); + } else { + T body = response.body(); + return body; + } + } catch (IOException e) { + throw new SystemException("System Error", e); + } + } +} diff --git a/core/src/main/java/com/databasir/core/infrastructure/remote/github/TokenRequest.java b/core/src/main/java/com/databasir/core/infrastructure/remote/github/TokenRequest.java new file mode 100644 index 0000000..4392473 --- /dev/null +++ b/core/src/main/java/com/databasir/core/infrastructure/remote/github/TokenRequest.java @@ -0,0 +1,32 @@ +package com.databasir.core.infrastructure.remote.github; + +import lombok.Builder; +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +@Data +@SuppressWarnings("checkstyle:all") +@Builder +public class TokenRequest { + + private String client_id; + + private String client_secret; + + private String code; + + private String redirect_uri; + + public Map toMap() { + HashMap map = new HashMap<>(); + map.put("client_id", client_id); + map.put("client_secret", client_secret); + map.put("code", code); + if (redirect_uri != null) { + map.put("redirect_uri", redirect_uri); + } + return map; + } +} diff --git a/dao/src/main/java/com/databasir/dao/enums/OAuthAppType.java b/dao/src/main/java/com/databasir/dao/enums/OAuthAppType.java new file mode 100644 index 0000000..fb2f082 --- /dev/null +++ b/dao/src/main/java/com/databasir/dao/enums/OAuthAppType.java @@ -0,0 +1,10 @@ +package com.databasir.dao.enums; + +public enum OAuthAppType { + + GITHUB, GITLAB; + + public boolean isSame(String type) { + return this.name().equalsIgnoreCase(type); + } +} diff --git a/dao/src/main/java/com/databasir/dao/impl/OAuthAppDao.java b/dao/src/main/java/com/databasir/dao/impl/OAuthAppDao.java index 915235c..ad619f7 100644 --- a/dao/src/main/java/com/databasir/dao/impl/OAuthAppDao.java +++ b/dao/src/main/java/com/databasir/dao/impl/OAuthAppDao.java @@ -1,22 +1,37 @@ package com.databasir.dao.impl; -import com.databasir.dao.tables.OauthApp; +import com.databasir.dao.exception.DataNotExistsException; +import com.databasir.dao.tables.pojos.OauthAppPojo; import lombok.Getter; import org.jooq.DSLContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; +import java.util.Optional; + import static com.databasir.dao.Tables.OAUTH_APP; @Repository -public class OAuthAppDao extends BaseDao { +public class OAuthAppDao extends BaseDao { @Autowired @Getter private DSLContext dslContext; public OAuthAppDao() { - super(OAUTH_APP, OauthApp.class); + super(OAUTH_APP, OauthAppPojo.class); } + public Optional selectOptionByRegistrationId(String registrationId) { + return this.getDslContext() + .select(OAUTH_APP.fields()).from(OAUTH_APP).where(OAUTH_APP.REGISTRATION_ID.eq(registrationId)) + .fetchOptionalInto(OauthAppPojo.class); + } + + public OauthAppPojo selectByRegistrationId(String registrationId) { + return this.getDslContext() + .select(OAUTH_APP.fields()).from(OAUTH_APP).where(OAUTH_APP.REGISTRATION_ID.eq(registrationId)) + .fetchOptionalInto(OauthAppPojo.class) + .orElseThrow(() -> new DataNotExistsException("can not found oauth app by " + registrationId)); + } } \ No newline at end of file diff --git a/dao/src/main/resources/db/migration/V1.4__oauth.sql b/dao/src/main/resources/db/migration/V1.4__oauth.sql new file mode 100644 index 0000000..46e705e --- /dev/null +++ b/dao/src/main/resources/db/migration/V1.4__oauth.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS oauth_app +( + id INT PRIMARY KEY NOT NULL AUTO_INCREMENT, + registration_id VARCHAR(100) NOT NULL, + app_name VARCHAR(128) NOT NULL, + app_icon VARCHAR(256) NOT NULL DEFAULT '', + app_type VARCHAR(64) NOT NULL COMMENT 'github, gitlab', + client_id VARCHAR(256), + client_secret VARCHAR(256), + auth_url VARCHAR(256), + resource_url VARCHAR(256), + scope VARCHAR(256), + update_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE uk_registration_id (registration_id) +) CHARSET utf8mb4 + COLLATE utf8mb4_unicode_ci COMMENT 'oauth app info'; +