feat: support github oauth

This commit is contained in:
vran 2022-02-28 23:12:44 +08:00
parent ad8cbab226
commit 18edc58d2f
20 changed files with 509 additions and 6 deletions

View File

@ -53,4 +53,5 @@ public class LoginController {
throw e;
}
}
}

View File

@ -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<String, String> 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<List<OAuthAppResponse>> listApps() {
return JsonData.ok(oAuthAppService.listAll());
}
}

View File

@ -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()

View File

@ -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
spring.flyway.locations=classpath:db/migration

View File

@ -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}"
}

View File

@ -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();
}

View File

@ -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<String, String> 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;
}
}

View File

@ -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<OAuthHandler> oAuthHandlers;
private final OAuthAppDao oAuthAppDao;
private final UserService userService;
private final LoginService loginService;
public LoginKeyResponse oauthCallback(String registrationId, Map<String, String> 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<OAuthAppResponse> listAll() {
return null;
}
}

View File

@ -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);
}

View File

@ -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<String, String> callbackParameters = new HashMap<>();
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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<JsonNode> getEmail(@Url String url, @Header("Authorization") String token);
@GET
@Headers(value = {
"Accept: application/json"
})
Call<JsonNode> getProfile(@Url String url, @Header("Authorization") String token);
}

View File

@ -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<JsonNode> getAccessToken(@Url String url, @QueryMap Map<String, String> request);
}

View File

@ -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> T execute(Call<T> call) {
try {
Response<T> 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);
}
}
}

View File

@ -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<String, String> toMap() {
HashMap<String, String> 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;
}
}

View File

@ -0,0 +1,10 @@
package com.databasir.dao.enums;
public enum OAuthAppType {
GITHUB, GITLAB;
public boolean isSame(String type) {
return this.name().equalsIgnoreCase(type);
}
}

View File

@ -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<OauthApp> {
public class OAuthAppDao extends BaseDao<OauthAppPojo> {
@Autowired
@Getter
private DSLContext dslContext;
public OAuthAppDao() {
super(OAUTH_APP, OauthApp.class);
super(OAUTH_APP, OauthAppPojo.class);
}
public Optional<OauthAppPojo> 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));
}
}

View File

@ -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';