이 문서는 가리사니 개발자 포럼에 올렸던 글의 백업 파일입니다. 오래된 문서가 많아 현재 상황과 맞지 않을 수 있습니다.
Spring Security OAuth 통합 로그인 시리즈
서론 (잡담)
필자가 운영하고 있는 가리사니도 사로를 통해서 페이스북과 네이버 OAuth 처리가 되어있는 것을 확인할 수 있습니다. 다만 이 글을 쓰는 시점으로 가리사니와 사로는 스프링이 아닌 서블릿으로 작성된 사이트이며 1장에서 소개한 구조를 통해 그냥 자바로 끄적끄적 작성한 OAuth 입니다. 이번에 스프링으로 사이트를 리뉴얼하면서 OAuth를 작성하려는데.. 스프링의 OAuth Client를 처음 본 순간 '그냥 생자바로 짜는 것이 10배는 더 간단하겠다' 라고 생각했습니다. 그래서 이 강의를 작성하게 되었습니다.
구조
- 사이트에는 스프링 시큐리티로 작성된 일반적인 로그인이 존재합니다.
- 스프링 시큐리티 참고 : /2016/07/24/%EB%B0%B1%EC%97%85-%EA%B0%80%EB%A6%AC%EC%82%AC%EB%8B%88-Spring-boot-Security-1.-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%84%A4%EC%A0%95.html
- DB에 일반 Account가 있고 OAuth 테이블과는 1:n 관계입니다. (하나의 계정에 여러개의 OAuth를 접합시킬 수 있다는 가정하에)
- OAuth로 로그인을 할 경우 유저ID와 전자우편을 가져옵니다.
- DB에 유저ID가 등록되지 않은 경우.
- OAuth 전용 가입페이지로 이동시킵니다. (간단히 생일정도만 적고 가입완료)
- DB에 유저ID가 등록되어 있는 경우.
- 해당 유저ID를 기준으로 Account를 찾아내서 위 직접 로그인의 Authentication 로 교체합니다.
- DB에 유저ID가 등록되지 않은 경우.
스프링에서 제공되는 OAuth 로그인
- 스프링 소셜을 이용하는경우.
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-security</artifactId>
</dependency>
<!-- 페이스북 -->
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-facebook</artifactId>
</dependency>
페이스북, 트위터, 링크드인등을 제공해줍니다.
장점 : 소셜기능도 매우 간단하게 사용할 수 있습니다.
단점 : 제공해주지 않는 서비스(네이버, 카카오등..)에 대한 구현이 상당한 노가다입니다..... DI가.. 활용되지 못하는 느낌
결론 : 필자가 필요한건 단지 인증부 입니다. 이걸 다 구현하면 배보다 배꼽이 더 커질 수 있음으로 패스합니다.
이것으로 구현하실 분들은 org.springframework.social.security.SocialUserDetailsService 를 상속하여 시큐리티와 연계 구현하는 방법이 있습니다.
- OAuth2 SSO(싱글 사인온)
- [https://spring.io/guides/tutorials/spring-boot-oauth2/#_social_login_simple 결론 : 예제에서도 볼 수 있듯 심플용으로 만들어진 것으로 기본적인 것들이 이미 구현되어 있는데 이름에서도 알 수 있듯 간단 구현용이라서 생략하겠습니다.
- OAuth2 매뉴얼(수동)
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
[https://spring.io/guides/tutorials/spring-boot-oauth2/ 소셜과 달리 설정파일을 주입하는 방식으로 설정파일 몇 줄과 코드 몇줄만 추가하면 소셜에선 제공해 주지 않는 네이버나 카카오등도 쉽게 확장할 수 있습니다.
구조
Account : 계정 AccountRole : 계정 권한 (계정1 : n 권한) [이 강의에선 쓰이지 않습니다.] AccountOauthClient : OAuth 계정 연계 (계정1 : n OAuth) web.configuration.security : 시큐리티 설정 web.configuration.security.direct : 직접 로그인 web.configuration.security.oauth2 : oauth2 로그인
직접 로그인 구현
스프링 시큐리티, 하이버네이트를 기준으로 구현됩니다. 주제가 OAuth 연동인만큼 일부분 생략하겠습니다. 스프링 시큐리티 강의는 아래 주소에서 볼 수 있습니다.
의존성
<!-- 스프링 시큐리티 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
룰 타입
- 기본적으로 USER 접속인지 OAUTH 접속인지를 구분합니다.
public enum RoleType
{
USER,
OAUTH
}
시큐리티
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
@Autowired
ApplicationContext context;
@Override
public void configure(WebSecurity web) throws Exception
{
// 메인페이지 : css나 js 같은것들도 여기에 포함시켜준다.
// web.ignoring().antMatchers("/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception
{
http
// 강의 특성상 전부 허용으로 작업하겠습니다.
.authorizeRequests()
.antMatchers("/**")
.permitAll()
.and()
.logout()
.logoutUrl("/sign-out")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.and()
.formLogin()
.loginPage("/sign-in")
.loginProcessingUrl("/sign-in/auth")
.failureUrl("/sign-in?error=exception")
.defaultSuccessUrl("/")
.and()
// 여기 나오는 sso.filter 빈은 다음장에서 작성합니다.
// 이 장에서 실행을 확인하시려면 당연히 NPE 오류가 나니 아래 소스에 주석을 걸어주시기 바랍니다.
.addFilterBefore((Filter)context.getBean("sso.filter"), BasicAuthenticationFilter.class);
}
}
DB 구조
@Entity(name="account")
@Table(name="account")
@EntityListeners(ColumnSaveOptionListener.class)
@Data @Getter @Setter @ToString
public class Account
{
@Id
@Column(name="sn", nullable=false, unique=true)
@SequenceGenerator(name="account_sn_seq", sequenceName="account_sn_seq", allocationSize=1)
@GeneratedValue(strategy=GenerationType.SEQUENCE, generator="account_sn_seq")
long sn;
@Column(name="mail", nullable=false, length=64, unique=true)
String mail;
@Column(name="auth", nullable=false, length=128)
String auth;
// 컬럼 생략...
@OneToMany(mappedBy="sn", fetch = FetchType.LAZY)
List<AccountRole> roles;
}
계정 권한
@Entity(name="account_role")
@Table
(
name="account_role",
uniqueConstraints=@UniqueConstraint(columnNames={"sn", "role"})
)
@Data @Getter @Setter @ToString
public class AccountRole
{
@Id
@Column(name="sn", nullable=false)
long sn;
@Column(name="role", nullable=false, length=64)
String role;
@Column(name="role_date")
Date roleDate;
}
계정 OAuth 연계 정보
@Entity(name="account_oauth_client")
@Table
(
name="account_oauth_client",
uniqueConstraints=@UniqueConstraint(columnNames={"sn", "type"})
)
@Data @Getter @Setter @ToString
public class AccountOauthClient
{
@Id
@Column(name="sn", nullable=false)
long sn;
@Column(name="type", nullable=false, length=12)
String type;
@Column(name="id", nullable=false, length=64)
String id;
}
계정 리포지토리
public interface AccountRepository extends JpaRepository<Account, Long>
{
@Query("SELECT a FROM account a LEFT JOIN FETCH a.roles b WHERE a.mail = lower(:mail)")
public Account findByMail(@Param("mail") String mail);
@Query("SELECT a FROM account a LEFT JOIN FETCH a.roles b WHERE a.sn = (SELECT sn FROM account_oauth_client WHERE type = :type AND id = :id)")
public Account findByOAuthId(@Param("type") String type, @Param("id") String id);
}
계정 서비스
@Component
public class AccountService
{
@Autowired
AccountRepository accountRepository;
public List<Account> findAll()
{
return accountRepository.findAll();
}
public Account getAccountByMail(String mail)
{
return accountRepository.findByMail(mail);
}
public Account getAccountByOAuthId(String type, String id)
{
return accountRepository.findByOAuthId(type, id);
}
}
인증 설정 어뎁터
@Configuration
public class AuthenticationConfig extends GlobalAuthenticationConfigurerAdapter
{
@Autowired
UserDetailsService userDetailsService;
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Bean
PasswordEncoder passwordEncoder()
{
// 예제입니다.
// 본인이 사용하는 패스워드 인코더를 쓰시면됩니다.
return new SaroPasswordEncoder();
}
}
UserDetails
@Data
@EqualsAndHashCode(callSuper=false)
public class UserDetails extends User
{
private static final long serialVersionUID = 1L;
@Getter
private long sn;
public UserDetails(Account account)
{
super
(
account.getMail(),
account.getAuth(),
getAuthorities(account.getRoles())
);
sn = account.getSn();
}
// 이 부분은 나중에 OAuth에서도 쓰이는 부분입니다.!!
// 기본적으로 USER : RoleType.USER.toString() 를 주고 계정에 추가 권한을 줍니다.
public static List<GrantedAuthority> getAuthorities(List<AccountRole> roles)
{
List<GrantedAuthority> list = new ArrayList<>(1);
list.add(new SimpleGrantedAuthority(RoleType.USER.toString()));
if (roles != null)
{
roles.stream().forEach((AccountRole role) ->
{
list.add(new SimpleGrantedAuthority(role.getRole()));
});
}
return list;
}
}
UserDetailsService
@Service
public class UserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService
{
@Autowired
AccountService accountService;
@Override
public UserDetails loadUserByUsername(String ac) throws UsernameNotFoundException
{
Account account = accountService.getAccountByMail(ac);
if (account == null)
{
throw new UsernameNotFoundException("sign-in fail");
}
return new UserDetails(account);
}
}
추신
스프링 시큐리티를 통해 직접 로그인을 구현해 본신 분이라면 특별한점이 별로 없다는 것을 알 수 있습니다.
- 스프링 시큐리티 참고 참고해서 봐야할 부분은 아래와 같습니다.
- UserDetails.getAuthorities() 부분
- SecurityConfig의 .addFilterBefore((Filter)context.getBean("sso.filter"), BasicAuthenticationFilter.class);
- DB 부분 여기 까지 작성했다면 로그인이 잘되는지 뷰를 만들고 .addFilterBefore((Filter)context.getBean("sso.filter"), BasicAuthenticationFilter.class); 부분에 주석을 건 후 실행해 보시기 바랍니다. (물론 완료하고 주석을 해제합니다.)