Feature/oauth2 github (#27)

* feat: jooq generate

* feat: support github oauth

* feat: support oauth2 login

* feat: update content-type

* feat: add custom authentication exception

* feat: add oauth2 app api

* fix: checkstyle
This commit is contained in:
vran
2022-03-02 20:13:25 +08:00
committed by GitHub
parent e799940c2d
commit cd20dfd7cf
52 changed files with 2120 additions and 63 deletions

View File

@@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor;
public enum DomainErrors implements DatabasirErrors {
REFRESH_TOKEN_EXPIRED("X_0001", "refresh token expired"),
INVALID_REFRESH_TOKEN_OPERATION("X_0002", "invalid refresh token operation"),
NETWORK_ERROR("X_0003", "网络似乎不稳定,请稍后再试"),
NOT_SUPPORT_DATABASE_TYPE("A_10000", "不支持的数据库类型, 请检查项目配置"),
PROJECT_NOT_FOUND("A_10001", "项目不存在"),
@@ -23,7 +24,8 @@ public enum DomainErrors implements DatabasirErrors {
CANNOT_UPDATE_SELF_ROLE("A_10009", "无法对自己执行角色变更的操作"),
UPDATE_PASSWORD_CONFIRM_FAILED("A_10010", "两次密码输入不一致"),
ORIGIN_PASSWORD_NOT_CORRECT("A_10011", "原密码不正确"),
INVALID_CRON_EXPRESSION("A_10012", "不合法的 cron 表达式");
INVALID_CRON_EXPRESSION("A_10012", "不合法的 cron 表达式"),
REGISTRATION_ID_DUPLICATE("A_10013", "应用注册 ID 不能重复");
private final String errCode;

View File

@@ -0,0 +1,111 @@
package com.databasir.core.domain.app;
import com.databasir.core.domain.DomainErrors;
import com.databasir.core.domain.app.converter.OAuthAppPojoConverter;
import com.databasir.core.domain.app.converter.OAuthAppResponseConverter;
import com.databasir.core.domain.app.data.*;
import com.databasir.core.domain.app.handler.OpenAuthHandler;
import com.databasir.core.domain.app.handler.OAuthProcessContext;
import com.databasir.core.domain.app.handler.OAuthProcessResult;
import com.databasir.core.domain.user.data.UserCreateRequest;
import com.databasir.core.domain.user.data.UserDetailResponse;
import com.databasir.core.domain.user.service.UserService;
import com.databasir.dao.impl.OauthAppDao;
import com.databasir.dao.tables.pojos.OauthAppPojo;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class OpenAuthAppService {
private final List<OpenAuthHandler> openAuthHandlers;
private final OauthAppDao oauthAppDao;
private final UserService userService;
private final OAuthAppResponseConverter oauthAppResponseConverter;
private final OAuthAppPojoConverter oauthAppPojoConverter;
public UserDetailResponse oauthCallback(String registrationId, Map<String, String[]> params) {
// match handler
OauthAppPojo app = oauthAppDao.selectByRegistrationId(registrationId);
OpenAuthHandler openAuthHandler = openAuthHandlers.stream()
.filter(handler -> handler.support(app.getAppType()))
.findFirst()
.orElseThrow(() -> new UsernameNotFoundException("暂不支持该类型登陆"));
// process by handler
OAuthProcessContext context = OAuthProcessContext.builder()
.callbackParameters(params)
.registrationId(registrationId)
.build();
OAuthProcessResult result = openAuthHandler.process(context);
// get or create new user
return userService.get(result.getEmail())
.orElseGet(() -> {
UserCreateRequest user = new UserCreateRequest();
user.setUsername(result.getUsername());
user.setNickname(result.getNickname());
user.setEmail(result.getEmail());
user.setAvatar(result.getAvatar());
user.setEnabled(true);
user.setPassword(UUID.randomUUID().toString().substring(0, 6));
Integer id = userService.create(user);
return userService.get(id);
});
}
public List<OAuthAppResponse> listAll() {
List<OauthAppPojo> apps = oauthAppDao.selectAll();
return apps.stream()
.map(oauthAppResponseConverter::to)
.collect(Collectors.toList());
}
public void deleteById(Integer id) {
if (oauthAppDao.existsById(id)) {
oauthAppDao.deleteById(id);
}
}
public void updateById(OAuthAppUpdateRequest request) {
OauthAppPojo pojo = oauthAppPojoConverter.of(request);
try {
oauthAppDao.updateById(pojo);
} catch (DuplicateKeyException e) {
throw DomainErrors.REGISTRATION_ID_DUPLICATE.exception();
}
}
public Integer create(OAuthAppCreateRequest request) {
OauthAppPojo pojo = oauthAppPojoConverter.of(request);
try {
return oauthAppDao.insertAndReturnId(pojo);
} catch (DuplicateKeyException e) {
throw DomainErrors.REGISTRATION_ID_DUPLICATE.exception();
}
}
public Page<OAuthAppPageResponse> listPage(Pageable page, OAuthAppPageCondition condition) {
return oauthAppDao.selectByPage(page, condition.toCondition()).map(oauthAppPojoConverter::toPageResponse);
}
public Optional<OAuthAppDetailResponse> getOne(Integer id) {
return oauthAppDao.selectOptionalById(id).map(oauthAppPojoConverter::toDetailResponse);
}
}

View File

@@ -0,0 +1,26 @@
package com.databasir.core.domain.app.converter;
import com.databasir.core.domain.app.data.OAuthAppCreateRequest;
import com.databasir.core.domain.app.data.OAuthAppDetailResponse;
import com.databasir.core.domain.app.data.OAuthAppPageResponse;
import com.databasir.core.domain.app.data.OAuthAppUpdateRequest;
import com.databasir.dao.tables.pojos.OauthAppPojo;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface OAuthAppPojoConverter {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createAt", ignore = true)
@Mapping(target = "updateAt", ignore = true)
OauthAppPojo of(OAuthAppCreateRequest request);
@Mapping(target = "createAt", ignore = true)
@Mapping(target = "updateAt", ignore = true)
OauthAppPojo of(OAuthAppUpdateRequest request);
OAuthAppPageResponse toPageResponse(OauthAppPojo pojo);
OAuthAppDetailResponse toDetailResponse(OauthAppPojo pojo);
}

View File

@@ -0,0 +1,11 @@
package com.databasir.core.domain.app.converter;
import com.databasir.core.domain.app.data.OAuthAppResponse;
import com.databasir.dao.tables.pojos.OauthAppPojo;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface OAuthAppResponseConverter {
OAuthAppResponse to(OauthAppPojo pojo);
}

View File

@@ -0,0 +1,35 @@
package com.databasir.core.domain.app.data;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class OAuthAppCreateRequest {
@NotNull
private String registrationId;
@NotBlank
private String appName;
@NotBlank
private String appType;
private String appIcon;
@NotBlank
private String authUrl;
@NotBlank
private String resourceUrl;
@NotBlank
private String clientId;
@NotBlank
private String clientSecret;
private String scope;
}

View File

@@ -0,0 +1,31 @@
package com.databasir.core.domain.app.data;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OAuthAppDetailResponse {
private Integer id;
private String appName;
private String appIcon;
private String appType;
private String registrationId;
private String clientId;
private String clientSecret;
private String authUrl;
private String resourceUrl;
private LocalDateTime updateAt;
private LocalDateTime createAt;
}

View File

@@ -0,0 +1,30 @@
package com.databasir.core.domain.app.data;
import com.databasir.dao.Tables;
import lombok.Data;
import org.jooq.Condition;
import org.jooq.impl.DSL;
import java.util.ArrayList;
import java.util.List;
@Data
public class OAuthAppPageCondition {
private String appNameContains;
private String appType;
public Condition toCondition() {
List<Condition> conditions = new ArrayList<>();
if (appNameContains != null && !appNameContains.trim().equals("")) {
conditions.add(Tables.OAUTH_APP.APP_NAME.contains(appNameContains));
}
if (appType != null) {
conditions.add(Tables.OAUTH_APP.APP_TYPE.eq(appType));
}
return conditions.stream()
.reduce(Condition::and)
.orElse(DSL.trueCondition());
}
}

View File

@@ -0,0 +1,30 @@
package com.databasir.core.domain.app.data;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OAuthAppPageResponse {
private Integer id;
private String appName;
private String appIcon;
private String appType;
private String registrationId;
private String clientId;
private String authUrl;
private String resourceUrl;
private LocalDateTime updateAt;
private LocalDateTime createAt;
}

View File

@@ -0,0 +1,28 @@
package com.databasir.core.domain.app.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 appName;
private String appIcon;
private String appType;
private String registrationId;
private LocalDateTime createAt;
}

View File

@@ -0,0 +1,39 @@
package com.databasir.core.domain.app.data;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class OAuthAppUpdateRequest {
@NotNull
private Integer id;
@NotBlank
private String registrationId;
@NotBlank
private String appName;
@NotBlank
private String appType;
private String appIcon;
@NotBlank
private String authUrl;
@NotBlank
private String resourceUrl;
@NotBlank
private String clientId;
@NotBlank
private String clientSecret;
private String scope;
}

View File

@@ -0,0 +1,19 @@
package com.databasir.core.domain.app.exception;
import com.databasir.common.DatabasirException;
import org.springframework.security.core.AuthenticationException;
public class DatabasirAuthenticationException extends AuthenticationException {
public DatabasirAuthenticationException(DatabasirException databasirException) {
super(databasirException.getErrMessage(), databasirException);
}
public DatabasirAuthenticationException(String msg) {
super(msg);
}
public DatabasirAuthenticationException(String msg, Throwable cause) {
super(msg, cause);
}
}

View File

@@ -0,0 +1,83 @@
package com.databasir.core.domain.app.handler;
import com.databasir.core.domain.DomainErrors;
import com.databasir.core.domain.app.exception.DatabasirAuthenticationException;
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.jooq.tools.StringUtils;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class GithubOpenAuthHandler implements OpenAuthHandler {
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")[0];
JsonNode tokenNode = githubRemoteService.getToken(baseUrl, clientId, clientSecret, code)
.get("access_token");
if (tokenNode == null) {
throw new DatabasirAuthenticationException(DomainErrors.NETWORK_ERROR.exception());
}
String accessToken = tokenNode.asText();
if (StringUtils.isBlank(accessToken)) {
throw new CredentialsExpiredException("授权失效,请重新登陆");
}
String email = null;
for (JsonNode node : githubRemoteService.getEmail(baseUrl, accessToken)) {
if (node.get("primary").asBoolean()) {
email = node.get("email").asText();
}
}
if (StringUtils.isBlank(email)) {
throw new CredentialsExpiredException("授权失效,请重新登陆");
}
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,22 @@
package com.databasir.core.domain.app.handler;
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.domain.app.handler;
import lombok.Data;
@Data
public class OAuthProcessResult {
private String email;
private String username;
private String nickname;
private String avatar;
}

View File

@@ -0,0 +1,10 @@
package com.databasir.core.domain.app.handler;
public interface OpenAuthHandler {
boolean support(String oauthAppType);
String authorization(String registrationId);
OAuthProcessResult process(OAuthProcessContext context);
}

View File

@@ -1,4 +1,4 @@
package com.databasir.core.domain.user.data;
package com.databasir.core.domain.login.data;
import lombok.Data;
@@ -15,6 +15,8 @@ public class UserLoginResponse {
private String username;
private String avatar;
private String accessToken;
private long accessTokenExpireAt;

View File

@@ -4,11 +4,14 @@ import com.databasir.core.domain.DomainErrors;
import com.databasir.core.domain.login.data.AccessTokenRefreshRequest;
import com.databasir.core.domain.login.data.AccessTokenRefreshResponse;
import com.databasir.core.domain.login.data.LoginKeyResponse;
import com.databasir.core.domain.login.data.UserLoginResponse;
import com.databasir.core.infrastructure.jwt.JwtTokens;
import com.databasir.dao.impl.LoginDao;
import com.databasir.dao.impl.UserDao;
import com.databasir.dao.impl.UserRoleDao;
import com.databasir.dao.tables.pojos.LoginPojo;
import com.databasir.dao.tables.pojos.UserPojo;
import com.databasir.dao.tables.pojos.UserRolePojo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -16,8 +19,11 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -28,6 +34,8 @@ public class LoginService {
private final UserDao userDao;
private final UserRoleDao userRoleDao;
private final JwtTokens jwtTokens;
public AccessTokenRefreshResponse refreshAccessTokens(AccessTokenRefreshRequest request) {
@@ -91,4 +99,36 @@ public class LoginService {
.refreshTokenExpireAt(refreshTokenExpireAt)
.build();
}
public Optional<UserLoginResponse> getUserLoginData(Integer userId) {
return loginDao.selectByUserId(userId)
.map(login -> {
UserPojo user = userDao.selectById(login.getUserId());
UserLoginResponse data = new UserLoginResponse();
data.setId(user.getId());
data.setNickname(user.getNickname());
data.setEmail(user.getEmail());
data.setUsername(user.getUsername());
data.setAccessToken(login.getAccessToken());
data.setAvatar(user.getAvatar());
long expireAt = login.getAccessTokenExpireAt()
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
data.setAccessTokenExpireAt(expireAt);
data.setRefreshToken(login.getRefreshToken());
List<UserRolePojo> rolePojoList =
userRoleDao.selectByUserIds(Collections.singletonList(user.getId()));
List<UserLoginResponse.RoleResponse> roles = rolePojoList
.stream()
.map(ur -> {
UserLoginResponse.RoleResponse roleResponse = new UserLoginResponse.RoleResponse();
roleResponse.setRole(ur.getRole());
roleResponse.setGroupId(ur.getGroupId());
return roleResponse;
})
.collect(Collectors.toList());
data.setRoles(roles);
return data;
});
}
}

View File

@@ -1,16 +0,0 @@
package com.databasir.core.domain.user.data;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class UserLoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}

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();
}
@@ -91,6 +91,21 @@ public class UserService {
return userResponseConverter.detailResponse(pojo, roles, groupNameMapById);
}
public Optional<UserDetailResponse> get(String email) {
return userDao.selectByEmail(email)
.map(user -> {
List<UserRolePojo> roles = userRoleDao.selectByUserIds(Collections.singletonList(user.getId()));
List<Integer> groupIds = roles.stream()
.map(UserRolePojo::getGroupId)
.filter(Objects::nonNull)
.collect(toList());
Map<Integer, String> groupNameMapById = groupDao.selectInIds(groupIds)
.stream()
.collect(toMap(GroupPojo::getId, GroupPojo::getName));
return userResponseConverter.detailResponse(user, roles, groupNameMapById);
});
}
@Transactional
public String renewPassword(Integer userId) {
UserPojo userPojo = userDao.selectById(userId);

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() + ", response = " + response);
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;
}
}