잊지 않겠습니다.

Spring에서 xml이나 annotation을 이용한 component-scan에 의해서 등록되는 Bean중에서 @PersistenceContext의 동작원리는 다음과 같습니다.

ComponentScanBeanDefinitionParser

ComponentScanBeanDefinitionParser.registerComponents를 통해 Bean을 등록시켜준다. annotationConfig가 enable된 상태에서 context의 Bean들을 scan시켜줍니다.

protected void registerComponents(
        XmlReaderContext readerContext, Set<BeanDefinitionHolder> beanDefinitions, Element element) {

    Object source = readerContext.extractSource(element);
    CompositeComponentDefinition compositeDef = new CompositeComponentDefinition(element.getTagName(), source);

    for (BeanDefinitionHolder beanDefHolder : beanDefinitions) {
        compositeDef.addNestedComponent(new BeanComponentDefinition(beanDefHolder));
    }

    // Register annotation config processors, if necessary.
    boolean annotationConfig = true;
    if (element.hasAttribute(ANNOTATION_CONFIG_ATTRIBUTE)) {
        annotationConfig = Boolean.valueOf(element.getAttribute(ANNOTATION_CONFIG_ATTRIBUTE));
    }
    if (annotationConfig) {
        Set<BeanDefinitionHolder> processorDefinitions =
                AnnotationConfigUtils.registerAnnotationConfigProcessors(readerContext.getRegistry(), source);
        for (BeanDefinitionHolder processorDefinition : processorDefinitions) {
            compositeDef.addNestedComponent(new BeanComponentDefinition(processorDefinition));
        }
    }

    readerContext.fireComponentRegistered(compositeDef);
}

AnnotationConfigUtil

AnnotationConfigUtil을 통해 Annotation이 설정된 Bean들을 얻어오기 시작합니다. registerAnnotationConfigProcessors에 의해서 각각의 Type에 따라 다른 등록을 Process 처리하게 됩니다.

if (!registry.containsBeanDefinition(CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(ConfigurationClassPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, CONFIGURATION_ANNOTATION_PROCESSOR_BEAN_NAME));
}

if (!registry.containsBeanDefinition(AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(AutowiredAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, AUTOWIRED_ANNOTATION_PROCESSOR_BEAN_NAME));
}

if (!registry.containsBeanDefinition(REQUIRED_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(RequiredAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, REQUIRED_ANNOTATION_PROCESSOR_BEAN_NAME));
}

// Check for JSR-250 support, and if present add the CommonAnnotationBeanPostProcessor.
if (jsr250Present && !registry.containsBeanDefinition(COMMON_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition(CommonAnnotationBeanPostProcessor.class);
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, COMMON_ANNOTATION_PROCESSOR_BEAN_NAME));
}

// Check for JPA support, and if present add the PersistenceAnnotationBeanPostProcessor.
if (jpaPresent && !registry.containsBeanDefinition(PERSISTENCE_ANNOTATION_PROCESSOR_BEAN_NAME)) {
    RootBeanDefinition def = new RootBeanDefinition();
    try {
        ClassLoader cl = AnnotationConfigUtils.class.getClassLoader();
        def.setBeanClass(cl.loadClass(PERSISTENCE_ANNOTATION_PROCESSOR_CLASS_NAME));
    }
    catch (ClassNotFoundException ex) {
        throw new IllegalStateException(
                "Cannot load optional framework class: " + PERSISTENCE_ANNOTATION_PROCESSOR_CLASS_NAME, ex);
    }
    def.setSource(source);
    beanDefs.add(registerPostProcessor(registry, def, PERSISTENCE_ANNOTATION_PROCESSOR_BEAN_NAME));
}

처리되는 Bean들은 다음과 같습니다.

  • CONFIGURATIONANNOTATIONPROCESSORBEANNAME : @Configuration이 적용된 객체들을 처리하는 Process 입니다.
  • AUTOWIREDANNOTATIONPROCESSORBEANNAME : Bean 객체들의 @Autowired가 적용된 객체들을 처리하는 Process 입니다.
  • REQUIREDANNOTATIONPROCESSORBEANNAME : @Required Annotation이 처리됩니다.
  • COMMONANNOTATIONPROCESSORBEANNAME : EJB, JNDI 지원 객체들의 Property들을 처리하는 Process 입니다.
  • PERSISTENCEANNOTATIONPROCESSORBEANNAME : JPA를 처리합니다. 실질적으로 JPA의 EntityManagerFactory 객체의 경우에는 PersistenceAnnotationBeanPostProcess 에서 처리하게 됩니다.
PersistenceAnnotationBeanPostProcess

이제 마지막 코드입니다. PersistenceAnnotationBeanPostProcess는 @PersistenceContext로 지정된 Property에 EntityManagerFactory에서 새로운 EntityManager를 생성하거나, Transaction에 의해 기존ㅇ에 생성된 EntityManager를 반환시켜서 사용하게 해줍니다. 이에 대한 코드는 다음과 같습니다.

먼저, EntityManagerFactory를 생성하고 주입하고 있습니다.

public PersistenceElement(Member member, PropertyDescriptor pd) {
    super(member, pd);
    AnnotatedElement ae = (AnnotatedElement) member;
    PersistenceContext pc = ae.getAnnotation(PersistenceContext.class);
    PersistenceUnit pu = ae.getAnnotation(PersistenceUnit.class);
    Class<?> resourceType = EntityManager.class;
    if (pc != null) {
        if (pu != null) {
            throw new IllegalStateException("Member may only be annotated with either " +
                    "@PersistenceContext or @PersistenceUnit, not both: " + member);
        }
        Properties properties = null;
        PersistenceProperty[] pps = pc.properties();
        if (!ObjectUtils.isEmpty(pps)) {
            properties = new Properties();
            for (PersistenceProperty pp : pps) {
                properties.setProperty(pp.name(), pp.value());
            }
        }
        this.unitName = pc.unitName();
        this.type = pc.type();
        this.synchronizedWithTransaction = (synchronizationTypeAttribute == null ||
                "SYNCHRONIZED".equals(ReflectionUtils.invokeMethod(synchronizationTypeAttribute, pc).toString()));
        this.properties = properties;
    }
    else {
        resourceType = EntityManagerFactory.class;
        this.unitName = pu.unitName();
    }
    checkResourceType(resourceType);
}

다음에, 각 Resource에 EntityManager를 주입합니다.

@Override
protected Object getResourceToInject(Object target, String requestingBeanName) {
    // Resolves to EntityManagerFactory or EntityManager.
    if (this.type != null) {
        return (this.type == PersistenceContextType.EXTENDED ?
                resolveExtendedEntityManager(target, requestingBeanName) :
                resolveEntityManager(requestingBeanName));
    }
    else {
        // OK, so we need an EntityManagerFactory...
        return resolveEntityManagerFactory(requestingBeanName);
    }
}

최종적으로 얻어지는 EntityManager는 resolveEntityManager method를 통해서 주입되게 됩니다. EntityManager의 코드는 다음과 같습니다.

private EntityManager resolveEntityManager(String requestingBeanName) {
    // Obtain EntityManager reference from JNDI?
    EntityManager em = getPersistenceContext(this.unitName, false);
    if (em == null) {
        // No pre-built EntityManager found -> build one based on factory.
        // Obtain EntityManagerFactory from JNDI?
        EntityManagerFactory emf = getPersistenceUnit(this.unitName);
        if (emf == null) {
            // Need to search for EntityManagerFactory beans.
            emf = findEntityManagerFactory(this.unitName, requestingBeanName);
        }
        // Inject a shared transactional EntityManager proxy.
        if (emf instanceof EntityManagerFactoryInfo &&
                ((EntityManagerFactoryInfo) emf).getEntityManagerInterface() != null) {
            // Create EntityManager based on the info's vendor-specific type
            // (which might be more specific than the field's type).
            em = SharedEntityManagerCreator.createSharedEntityManager(
                    emf, this.properties, this.synchronizedWithTransaction);
        }
        else {
            // Create EntityManager based on the field's type.
            em = SharedEntityManagerCreator.createSharedEntityManager(
                    emf, this.properties, this.synchronizedWithTransaction, getResourceType());
        }
    }
    return em;
}

항시 SharedEntityManager를 이용해서 @Transactional annotation과 같이 사용할 수 있도록 동작하고 있는 것을 볼 수 있습니다. 코드를 보시면 아시겠지만, JPA를 사용하실 때는 반드시 @PersistenceContext annotation을 이용해야지 됩니다.

Posted by Y2K
,

기본적인 CRUD를 지원하는 GenericDAO를 구성하는 코드의 비교를 해볼려고 합니다.

일단 기술 배경으로는 Spring을 사용하고, Spring내에서의 Hibernate, JPA, 그리고 Hibernate를 이용한 queryDSL 코드간의 GenericDao를 비교해보고자 합니다.

기본적으로 Entity는 BaseEntity를 상속하는 것을 원칙으로 갖습니다. DB에서 생성되는 단일키를 사용하는 것으로 기본 구상을 잡고 들어가면 GenericDao의 interface는 다음과 같이 구성될 수 있습니다.

public interface EntityDao<T extends BaseEntity> {
    List<T> getAll();
    void deleteAll();
    T getById(int id);
    void add(T entity);
    void update(T entity);
    int countAll();
}

이제 이 interface를 구현한 AbstractDao class를 각 3개의 기술로 따로 구현해보고자 합니다.

Hibernate

Hibernate는 SessionFactory를 통해 생성되는 Session을 기반으로 구성합니다. Spring을 이용하기 때문에 SessionFactory.getCurrentSession() method를 이용해서 Session을 구해와서 처리합니다.

약간의 특징이라고 할 수 있는 것이 Criteria를 이용하는 코드의 경우, Class<?>를 반드시 각 EntityDao 생성 객체에서 넘겨주는 것이 필요합니다.

public abstract class AbstractSessionFactoryDao<T extends BaseEntity> implements EntityDao<T> {

    @Autowired
    protected SessionFactory sessionFactory;
    private final Class<?> clazz;

    protected AbstractSessionFactoryDao(Class<?> clazz) {
        this.clazz = clazz;
    }


    @Override
    public List<T> getAll() {
        Session session = sessionFactory.getCurrentSession();
        return session.createCriteria(clazz).list();
    }

    @Override
    public void deleteAll() {
        List<T> items = getAll();
        Session session = sessionFactory.getCurrentSession();
        for (T item : items) {
            session.delete(item);
        }
    }

    @Override
    public T getById(final int id) {
        Session session = sessionFactory.getCurrentSession();
        return (T)session.get(clazz, id);
    }

    @Override
    public void add(final T entity) {
        Session session = sessionFactory.getCurrentSession();
        session.save(entity);
    }

    @Override
    public void update(final T entity) {
        Session session = sessionFactory.getCurrentSession();
        session.update(entity);
    }

    @Override
    public int countAll() {
        Session session = sessionFactory.getCurrentSession();
        Long count = (Long) session.createCriteria(clazz)
                .setProjection(Projections.rowCount())
                .uniqueResult();

        if(count == null) {
            return 0;
        } else {
            return count.intValue();
        }
    }
}
JPA

jpa를 이용하는 경우, @PersistenceContext 를 이용해서 EntityManager를 등록해서 사용해주면 됩니다. Spring은 @PersistenceContext를 확인하면, 등록된 EntityManagerFactoryBean은 EntityManager를 사용할때마다 EntityManagerFactory에서 얻어와서 사용하게 됩니다. 이는 Spring에서 핸들링시켜줍니다. 이에 대한 코드 분석은 다음 Post에서 한번 소개해보도록 하겠습니다.

JPA를 이용한 GenericDao class의 코드는 다음과 같습니다.

public abstract class AbstractJpaDao<T extends BaseEntity> implements EntityDao<T> {

    protected AbstractJpaDao(String entityName) {
        this.entityName = entityName;
    }

    @PersistenceContext
    protected EntityManager em;

    @Autowired
    protected JpaExecutor executor;
    protected final String entityName;

    @Override
    public List<T> getAll() {
        return em.createQuery("from " + entityName).getResultList();
    }

    @Override
    public void deleteAll() {
        System.out.println(em);
        em.createQuery("delete from " + entityName).executeUpdate();
    }

    @Override
    public T getById(final int id) {
        Query query = em.createQuery("from " + entityName + " where id = :id");
        query.setParameter("id", id);
        return (T) query.getSingleResult();
    }

    @Override
    public void add(final T entity) {
        em.merge(entity);
    }

    @Override
    public void update(final T entity) {
        em.merge(entity);
    }

    @Override
    public int countAll() {
        return em.createQuery("from " + entityName).getResultList().size();
    }
}
Hibernate기반의 queryDSL

GenericDAO class를 만들기 위해서 QueryDsl에서 가장 문제가 되는 것은 QClass들의 존재입니다. Generator에서 생성되는 Q Class들은 EntityPathBase class에서 상속받는 객체들입니다. 따라서 다른 GenericDAO class 들과는 다르게 2개의 Generic Parameter를 받아야지 됩니다.

다음은 queryDSL을 이용한 GenericDAO code입니다.

public abstract class AbstractQueryDslDao<T extends BaseEntity, Q extends EntityPathBase<T>> implements EntityDao<T> {

    @Autowired
    protected SessionFactory sessionFactory;
    protected final Q q;
    protected final Class<?> clazz;

    protected AbstractQueryDslDao(Class<?> clazz, Q q) {
        this.clazz = clazz;
        this.q = q;
    }

    protected HibernateQuery getSelectQuery() {
        return new HibernateQuery(sessionFactory.getCurrentSession()).from(q);
    }

    protected HibernateDeleteClause getDeleteQuery() {
        return new HibernateDeleteClause(sessionFactory.getCurrentSession(), q);
    }

    protected HibernateUpdateClause getUpdateQuery() {
        return new HibernateUpdateClause(sessionFactory.getCurrentSession(), q);
    }


    @Override
    public List<T> getAll() {
        HibernateQuery query = getSelectQuery();
        return query.list(q);
    }

    @Override
    public void deleteAll() {
        getDeleteQuery().execute();
    }

    @Override
    public T getById(int id) {
        return (T)sessionFactory.getCurrentSession().get(clazz, id);
    }

    @Override
    public void add(T entity) {
        sessionFactory.getCurrentSession().saveOrUpdate(entity);
    }

    @Override
    public void update(T entity) {
        sessionFactory.getCurrentSession().saveOrUpdate(entity);
    }

    @Override
    public int countAll() {
        Long count = getSelectQuery().count();
        return count.intValue();
    }
}

개인적으로는 JPA에서 사용하는 JPQL의 문법이 조금 맘에 안듭니다. 거의 SQL query문을 객체를 이용해서 사용한다는 느낌만 들고 있기 때문에, 오타 등의 문제에서 자유롭지가 않습니다. 그런 면에서는 queryDSL을 이용하는 것이 가장 좋은 것 같지만 queryDSL의 경우에는 처음 초기 설정이 조금 까다롭습니다. 같이 일하는 팀원중 이런 설정을 잘 잡는 사람이 있으면 괜찮지만…

다음에는 글 내용중 언급한것처럼 @PersistenceContext Autowiring에 대해서 코드 분석을 해볼까합니다. ^^ 아무도 안오는 Blog지만요. ^^

Posted by Y2K
,

java에서 가장 대표적인 ORM인 Hibernate와 Hibernate 기반의 JPA연결 방법에 대해서 알아보도록 하겠습니다.

먼저, 모든 연결 방법은 Hibernate를 기반으로 구성되어 있습니다. 이는 Hibernate의 사상이 JPA의 기본이 되어버린 것이 큽니다. 따라서, Spring을 이용한 ORM의 접근방법이라고 하는 것이 대부분 Hibernate에 대한 연결이 되는 것이 일반적입니다.

Hibernate

Hibernate를 사용하기 위해서는 Configuration에 다음 2가지가 등록되어야지 됩니다.

  1. LocalSessionFactoryBean : SessionFactory에 대한 Factory Bean입니다. SessionFactory를 생성하는 객체를 등록시켜줍니다. 이는 Spring에서 사용할 DataSource와 Entity가 위치한 Package들에 대한 검색을 모두 포함하게 됩니다.
  2. HibernateTransactionManager : PlatformTransactionManager를 구현한 Hibernate용 TransactionManager를 등록해야지 됩니다. 이는 Spring에서 @EnableTransactionManager와 같이 사용되어 @Transactional annotation을 사용할 수 있게 됩니다.
LocalSessionFactory

SessionFactory를 생성하는 FactoryBean입니다. hibernate.cfg.xml 에서 설정되는 내용이 여기에 들어가게 됩니다.

  • Hibernate의 property를 등록합니다.
  • DataSource를 등록시킵니다.
  • Entity가 위치한 Package를 지정합니다.
    @Bean
    public LocalSessionFactoryBean sessionFactory() {
        Properties properties = new Properties();
        properties.setProperty("hibernate.dialect", env.getProperty("hibernate.dialect"));
        properties.setProperty("hibernate.show_sql", env.getProperty("hibernate.show_sql"));
        properties.setProperty("hibernate.format_sql", env.getProperty("hibernate.format_sql"));

        LocalSessionFactoryBean localSessionFactoryBean = new LocalSessionFactoryBean();
        localSessionFactoryBean.setDataSource(dataSource());
        localSessionFactoryBean.setHibernateProperties(properties);

        localSessionFactoryBean.setPackagesToScan(new String[] { "me.xyzlast.bh.entities" });
        return localSessionFactoryBean;
    }
HibernateTransactionManager

Transaction을 사용하기 위한 PlatformTransactionManager interface의 구현체를 등록합니다.

  • SessionFactory와 같이 사용될 DataSource를 등록합니다.
  • SessionFactoryBean에서 생성되는 SessionFactory를 등록시켜줍니다.
    @Bean
    public PlatformTransactionManager transactionManager() {
        HibernateTransactionManager transactionManager = new HibernateTransactionManager();
        transactionManager.setDataSource(dataSource());
        transactionManager.setSessionFactory(sessionFactory().getObject());
        transactionManager.setHibernateManagedSession(false);
        return transactionManager;
    }

JPA

Hibernate 기반의 JPA를 등록하기 위해서는 Hibernate-JPA support bean이 필요합니다. 이에 대한 maven repository의 gradle 설정은 다음과 같습니다.

    compile 'org.hibernate:hibernate-entitymanager:4.3.1.Final'

Spring에서 Hibernate기반의 JPA를 사용하기 위해서는 다음 Bean들이 필요합니다.

  1. LocalContainerEntityManagerFactoryBean : SessionFactoryBean과 동일한 역활을 맡습니다. SessionFactoryBean과 동일하게 DataSource와 Hibernate Property, Entity가 위치한 package를 지정합니다. 또한 Hibernate 기반으로 동작하는 것을 지정해하는 JpaVendor를 설정해줘야지 됩니다.
  2. HibernateJpaVendorAdapter : Hibernate vendor과 JPA간의 Adapter를 설정합니다. 간단히 showSql 정도의 Property만을 설정하면 됩니다. 매우 단순한 code입니다.
  3. JpaTransactionManager : DataSource와 EntityManagerFactoryBean에서 생성되는 EntityManagerFactory를 지정하는 Bean입니다.
LocalContainerEntityManagerFactoryBean

Hibernate에서 SessionFactoryBean과 동일한 역활을 담당하는 FactoryBean입니다. EntityManagerFactory를 생성하는 FactoryBean형태입니다. @Configuration 선언은 다음과 같습니다.

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
        LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
        entityManagerFactoryBean.setJpaVendorAdapter(hibernateJpaVendorAdapter());
        entityManagerFactoryBean.setDataSource(dataSource());
        entityManagerFactoryBean.setPackagesToScan("me.xyzlast.bh.entities");
        // NOTE : Properties를 이용해서 Hibernate property를 설정합니다.
        // entityManagerFactoryBean.setJpaProperties();
        return entityManagerFactoryBean;
    }
HibernateJpaVendorAdapter

JPA 규약과 Hibernate간의 Adapter 입니다. 특별히 설정할 내용은 존재하지 않고, showSQL 정도의 설정만이 존재합니다.

    @Bean
    public HibernateJpaVendorAdapter hibernateJpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setShowSql(true);
        return hibernateJpaVendorAdapter;
    }
JpaTransactionManager

JPA를 지원하는 TransactionManager를 등록합니다. DataSource와 EntityManagerFactory를 등록시켜주면 됩니다.

    @Bean
    public PlatformTransactionManager transactionManager() {
        JpaTransactionManager jpaTransactionManager = new JpaTransactionManager(entityManagerFactory().getObject());
        jpaTransactionManager.setDataSource(dataSource());
        return jpaTransactionManager;
    }


Posted by Y2K
,

aspectJ기술은 AOP를 보다 쉽고 강력하게 적용하기 위한 기술입니다. aspectJ는 다음과 같은 특징을 가지고 있습니다.

  • compile된 bytecode에 대한 re-compile을 통한 새로운 bytecode의 작성으로 인한 AOP가 갖는 성능 저하의 최소
  • pointcut, advisor의 직접적인 선언을 이용한 보기 쉬운 코드의 작성

먼저, AspectJ를 실행하기 위해서는 다음 3개의 maven repository의 등록이 필요합니다.

compile 'org.aspectj:aspectjrt:1.7.4'
compile 'org.aspectj:aspectjtools:1.7.4'
compile 'org.aspectj:aspectjweaver:1.7.4'

aspectJ는 중요한 3개의 단어 정의가 먼저 필요합니다.

  1. Pointcut : AOP가 적용되는 시점을 정의합니다. 정의 방법은 @annotation, expression, method, beanName 등의 방법이 있습니다.
  2. Advice : Pointcut과 연동되어 AOP가 적용될 method를 의미합니다. 전/후 처리 method를 의미합니다.
  3. Advisor : Point + Advice를 정의된 class를 의미합니다. Spring에서 aspectJ에 대한 지원을 하기 위한 방법으로 사용됩니다.

예시로 가장 간단히 사용할 수 있는 @annotation을 이용한 AOP에 대한 예제에 대해서 알아보도록 하겠습니다.

먼저 AOP target method를 지정할 @interface를 생성합니다.

public @interface ToLowerOutput {
}

생성된 interface를 지정하는 Pointcut과 Advice를 포함한 Advisor class를 생성합니다.

@Aspect
@Component
public class HelloAdvisor {
    @Pointcut(value = "@annotation(me.xyzlast.bookstore.aop.ToLowerOutput)")
    private void convertToLowercast() {

    }

    @Around(value = "convertToLowercast()")
    public Object convertResultStringToLower(ProceedingJoinPoint pjp) throws Throwable {
        return pjp.proceed().toString().toLowerCase();
    }
}

만들어진 @annotation을 class에 적용합니다.

@Component
public class HelloImpl implements Hello {
    @ToLowerOutput
    @Override
    public String sayHello(String name) {
        return "Hello " + name + "!";
    }

    @ToLowerOutput
    @Override
    public String sayHi(String name) {
        return "Hi! " + name + "!";
    }

    @ToLowerOutput
    @Override
    public String sayThankYou(String name) {
        return "Thank you, " + name + "!";
    }
}

Spring configuration을 다음과 같이 수정합니다. 주의할점은 반드시 annotation auto proxy 설정이 bean 정의보다 먼저 위치해야지 됩니다!

    <aop:aspectj-autoproxy/>
    <bean name="helloImpl" class="me.xyzlast.bookstore.aop.HelloImpl"/>
    <bean name="aspectJAOP" class="me.xyzlast.bookstore.aop.HelloAdvisor"/>

이제 test code를 이용하면 모든 값들이 다 소문자로 나오게 되는 것을 볼 수 있습니다.

Posted by Y2K
,

개발시, 외부 SOAP 서비스 또는 REST 서비스를 이용해서 인증을 처리해줘야지 되는 경우가 자주 발생하게 됩니다. 특히, 기존의 인증 시스템을 가지고 있는 상태에서 신규 개발은 기존의 인증시스템을 그대로 사용해야지 될 때, 이런 경우가 자주 생기게 됩니다.

일단. 이와 같은 상황에서는 가장 널리 사용되고 있는 DaoAuthentication을 사용하지 못합니다. Application의 Database에는 사용자의 정보를 전혀 가지지 못하게 되니까요. 이때, 인증 process만을 외부에서 사용하고, 인증된 Process를 거친 후, Spring Security FIlter Chain을 통과하게 된다면 가장 좋은 방법이 될 것 같습니다.

Spring Security 인증 구성

Spring Security에서는 여러가지 인증방법을 제공하는데, 이를 지원하기 위해서는 AuthenticationManagerBuilder에 AuthenticationProvider를 구현한 객체를 설정해줘야지 됩니다.

Spring Security에서 일반적으로 인증을 구성하는 방식은 다음과 같습니다.

  1. AuthenticationProvider 구성
  2. AuthenticationManager 구성 후, Provider에 Bean Setting
  3. AuthenticationManagerBuilder에 설정된 AuthenticationProvider 설정

가장 기본적이라고 할 수 있는 DaoAuthenticationProvider를 이용한 설정은 다음과 같습니다.

@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
    daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
    return daoAuthenticationProvider;
}

@Override
@Bean(name = BeanIds.USER_DETAILS_SERVICE)
public UserDetailsService userDetailsServiceBean() throws Exception {
    CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
    userDetailsService.setUserService(context.getBean(UserService.class));
    return userDetailsService;
}

RemoteAuthenticationProvider 의 구성

DaoAuthenticationProvider의 구성과 동일하게 코드는 다음과 같이 구성될 수 있습니다.

@Bean
public RemoteAuthenticationProvider remoteAuthenticationProvider() throws Exception {
    RemoteAuthenticationProvider provider = new RemoteAuthenticationProvider();
    provider.setRemoteAuthenticationManager(remoteAuthenticationManager());
    return provider;
}

여기에서 중요한 것이, setRemoteAuthenticationManager method입니다. 이제 외부에서 인증을 하는 Client code가 구성되는 곳이 RemoteAuthenticationManager이기 때문입니다.

RemoteAuthenticationManager interface는 다음과 같이 구현되어 있습니다.

public interface RemoteAuthenticationManager {
    Collection<? extends GrantedAuthority> attemptAuthentication(String username, String password) throws RemoteAuthenticationException;
}

매우 단순한 Interface입니다. 여기서 주의할 것이, username과 password는 모두 평문으로 들어오게 됩니다. DaoAuthenticationManager의 경우, Password를 따로 encoding을 할 수 있는 기능을 제공하지만 RemoteAuthenticationManager의 경우에는 plan text로 username과 password를 다뤄야지 됩니다. 하긴 이게 당연한것인지도 모릅니다. username과 password를 단순히 전달하는 역활을 하게 되는 것이 일반적이니까요.

개인적으로 만들어본 SOAP을 이용한 외부 서비스를 호출하는 RemoteAuthenticationManager입니다. RemoteAuthenticationManager를 통해 인증을 처리하고, 외부 SOAP API에 있는 사용자 정보를 Application DB에 옮기는 작업을 하도록 구성하였습니다. 그 이유는 UserDetailsService 때문입니다. UserDetailsService를 통해 username, password 이외의 사용자 정보를 활용할 수 있는 여지를 남겨주고 싶었습니다.

public class SsoAuthServiceImpl implements SsoAuthService, RemoteAuthenticationManager {
    private static final QName SERVICE_NAME = new QName("http://ws.daesung.co.kr/", "SSOWebServiceForJava");
    public static final String USERNAME_PASSWORD_IS_NOT_MATCHED = "username/password is not matched!";
    public static final String ROLE_USER = "ROLE_USER";

    @Getter
    @Setter
    private UserService userService;
    @Getter
    @Setter
    private ApplicationContext context;

    @Override
    public SsoAuthResult login(String username, String password) throws IOException {
        URL wsdlURL = SSOWebServiceForJava.WSDL_LOCATION;
        SSOWebServiceForJava service = new SSOWebServiceForJava(wsdlURL, SERVICE_NAME);
        SSOWebServiceForJavaSoap request = service.getSSOWebServiceForJavaSoap12();
        ArrayOfString result = request.ssoLoginArray(username, password, "NS");
        List<String> items = result.getString();

        SsoAuthResult authResult = context.getBean(SsoAuthResult.class);
        authResult.initialize(items);

        return authResult;
    }

    @Override
    public Collection<? extends GrantedAuthority> attemptAuthentication(String username, String password) throws RemoteAuthenticationException {
       try {

            SsoAuthResult authResult = login(username, password);
            if (authResult.isLoginSuccess()) {
                User user = userService.findByUsername(username);
                if(user == null) {
                    user = userService.addNewUser(username, authResult.getName(), password, ROLE_USER);
                } else {
                    user.setName(authResult.getName());
                    userService.updateUser(user);
                }
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                for(String role : user.getRoles()) {
                    authorities.add(new SimpleGrantedAuthority(role));
                }
                return authorities;
            } else {
                throw new RemoteAuthenticationException(USERNAME_PASSWORD_IS_NOT_MATCHED);
            }

        } catch (Exception e) {
            throw new RemoteAuthenticationException(USERNAME_PASSWORD_IS_NOT_MATCHED);
        }
//        return null;
    }
}

RemoteAuthenticationManager의 경우에는 attemptAuthentication method만을 갖는 interface입니다. 사용자의 username, password가 전달되기 때문에, 이를 이용해서 외부 API call을 해서 인증을 처리하는 것이 가능하게 됩니다.

이를 모두 반영시킨 Spring Security의 Configuration은 다음과 같습니다.

@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
@ImportResource(value = "classpath:cxf-servlet.xml")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

    @Autowired
    private UserService userService;
    @Autowired
    private ApplicationContext context;
    @Autowired
    private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .exceptionHandling().authenticationEntryPoint(corsEntryPoint)
             .and()
                .csrf().disable()
                .authorizeRequests()
                .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                .antMatchers("/api/apiKey/**").authenticated()
                .antMatchers("/api/auth/**").authenticated()
            .and()
                .addFilterAfter(digestAuthenticationFilter(), BasicAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(remoteAuthenticationProvider());
    }

    @Bean
    public RemoteAuthenticationProvider remoteAuthenticationProvider() throws Exception {
        RemoteAuthenticationProvider provider = new RemoteAuthenticationProvider();
        provider.setRemoteAuthenticationManager(remoteAuthenticationManager());
        return provider;
    }

    @Bean
    public RemoteAuthenticationManager remoteAuthenticationManager() throws Exception {
        SsoAuthServiceImpl ssoAuthService = new SsoAuthServiceImpl();
        ssoAuthService.setUserService(userService);
        ssoAuthService.setContext(context);
        return ssoAuthService;
    }

    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public SsoAuthService ssoAuthService() {
        return new SsoAuthServiceImpl();
    }

}

UserDetailsService는 반드시 구현하는 것이 좋습니다. 그리고 외부 서비스를 이용해서 로그인을 진행하더라도, 기본적인 사용자 정보을 반드시 Application DB안에 넣는 것이 좋을 것 같습니다.

Posted by Y2K
,

Gradle 2.0으로 업그레이드 된 버젼에 대해서 업데이트 된 글이 있습니다.


기존 gradle을 이용한 개발 환경 설정 업데이트입니다.


변경사항은 다음과 같습니다. 


1. jetty plugin의 제거 : 개발환경에서는 jetty / tomcat의 차이가 없을 것 같아, jetty를 없앴습니다.

2. spork를 이용한 테스트 환경 추가. : groovy base인 spork를 이용한 test 환경을 추가하였습니다.

3. tomcat plugin 사용시, tomcatRun 에러 수정 : tomcat plugin 사용시 tomcatRun 을 하면 servlet jar class not found 에러가 나오는 것을 수정했습니다. 

4. gradle 1.9 에서 동작하지 않던 버그를 수정했습니다.

5. build.gradle을 4개로 분리했습니다.

1) base.gradle : 기본 설정에 관련된 내용으로 구성됩니다.

2) domain.gradle : domain module에 관련된 내용으로 구성됩니다. (queryDsl QFile generate)

3) web.gradle : war에 관련된 내용으로 구성됩니다. (tomcatRun, tomcatRunWar)


build.gradle

apply plugin: 'base'
apply plugin: 'sonar-runner'
apply plugin: 'maven'
version = '1.0.0'

ext {
    javaVersion = 1.7
    springVersion = '4.0.0.RELEASE'
}


buildscript {
    repositories {
        mavenCentral()
        jcenter()
        maven { url 'http://repo.springsource.org/plugins-release' }
    }
    dependencies {
        classpath 'org.gradle.api.plugins:gradle-tomcat-plugin:1.0'
        classpath (group: 'org.gradle.api.plugins', name: 'gradle-cargo-plugin', version: '0.6.1')
        classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.1'
    }
}

allprojects {
    apply plugin: 'propdeps'
    apply plugin: 'propdeps-maven'
    apply plugin: 'propdeps-idea'
    apply plugin: 'propdeps-eclipse'

    repositories {
        mavenLocal()
        mavenCentral()
        jcenter()
        maven { url 'http://download.java.net/maven/2' }
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
        maven { url 'https://maven.java.net/content/repositories/releases'}
        maven { url 'http://repo.springsource.org/plugins-release' }
    }

    dependencies {
        provided 'org.projectlombok:lombok:0.12.0'
    }
}


apply from: 'base.gradle'
apply from: 'domain.gradle'
apply from: 'web.gradle'


base.gradle

subprojects {
    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'
    apply plugin: 'jacoco'
    apply plugin: 'groovy'

    sonarRunner {
        sonarProperties {
            property "sonar.sourceEncoding", "UTF-8"
        }
    }

    jacoco {
        toolVersion = '0.6.3.201306030806'
    }

    test {
        jacoco {
            append = false
            destinationFile = file("target/jacoco.exec")
            classDumpFile = file("target/classpathdumps")
        }
    }

    if(project.hasProperty('target')) {
        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-${project.target}"]
        }
    } else {
        String hostname = InetAddress.getLocalHost().getHostName();
        if(hostname.endsWith('.local')) {   //맥의 경우, .local 이 모든 hostname에 추가됩니다.
            hostname.replace(".local", '')
        }

        sourceSets {
            main.resources.srcDirs = ['src/main/resources', "src/main/resources-" + hostname]
        }
    }

    dependencies {
        def slf4jVersion = "1.7.2"
        compile "org.slf4j:jcl-over-slf4j:$slf4jVersion"
        compile "org.slf4j:jul-to-slf4j:$slf4jVersion"
        compile "org.slf4j:slf4j-api:$slf4jVersion"
        compile 'ch.qos.logback:logback-classic:1.0.13'

        testCompile "junit:junit:4.11"

        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
        testCompile "org.spockframework:spock-spring:1.0-groovy-2.0-SNAPSHOT"
    }

    sourceCompatibility = rootProject.ext.javaVersion
    targetCompatibility = rootProject.ext.javaVersion

    tasks.withType(Compile) {
        options.encoding = 'UTF-8'
    }
}

sonarRunner {
    sonarProperties {
        property "sonar.host.url", "http://192.168.13.209:9000"
        property "sonar.jdbc.url", "jdbc:mysql://192.168.13.209:3306/sonar"
        property "sonar.jdbc.driverClassName", "com.mysql.jdbc.Driver"
        property "sonar.jdbc.username", "root"
        property "sonar.jdbc.password", 'qwer12#$'
    }
}


domain.gradle

configure(subprojects.findAll { it.name.endsWith('domain') }) {
    dependencies {
        compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.22'
        compile("com.jolbox:bonecp:0.8.0.RELEASE") {
            exclude group: 'com.google.guava'
        }
        compile group: 'com.google.guava', name: 'guava', version: '15.0'
        compile group: 'org.springframework', name: 'spring-core', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-orm', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-tx', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context', version: "${rootProject.ext.springVersion}"
        compile group: 'org.springframework', name: 'spring-context-support', version: "${rootProject.ext.springVersion}"

        compile group: 'org.hibernate', name: 'hibernate-core', version: '4.1.10.Final'
        compile group: 'org.hibernate.javax.persistence', name: 'hibernate-jpa-2.0-api', version: '1.0.1. Final'
        compile group: 'org.hibernate', name: 'hibernate-entitymanager', version: '4.1.10.Final'
        compile group: 'org.hibernate', name: 'hibernate-validator', version: '4.3.1.Final'

        compile group: 'org.springframework.data', name: 'spring-data-jpa', version: '1.4.2.RELEASE'

        def queryDSL = '3.2.4'
        compile("com.mysema.querydsl:querydsl-core:$queryDSL")
        compile("com.mysema.querydsl:querydsl-jpa:$queryDSL")
        compile("com.mysema.querydsl:querydsl-sql:$queryDSL")
        provided("com.mysema.querydsl:querydsl-apt:$queryDSL") {
            exclude group: 'com.google.guava'
        }

    }

    sourceSets {
        generated {
            java {
                srcDirs = ['src/main/generated']
            }
        }
    }

    task generateQueryDSL(type: JavaCompile, group: 'build', description: 'Generates the QueryDSL query types') {
        source = sourceSets.main.java
        classpath = configurations.compile + configurations.provided
        options.compilerArgs = [
                "-proc:only",
                "-processor", "com.mysema.query.apt.jpa.JPAAnnotationProcessor"
        ]
        destinationDir = sourceSets.generated.java.srcDirs.iterator().next()
    }

    compileJava {
        dependsOn generateQueryDSL
        source generateQueryDSL.destinationDir
    }

    compileGeneratedJava {
        dependsOn generateQueryDSL
        options.warnings = false
        classpath += sourceSets.main.runtimeClasspath
    }

    clean {
        delete sourceSets.generated.java.srcDirs
    }

    idea {
        module {
            sourceDirs += file('src/main/generated')
        }
    }
}



web.gradle

configure(subprojects.findAll { it.name.endsWith('Web') }) {
    apply plugin: 'war'
    apply plugin: 'tomcat'
    apply plugin: 'cargo'

    dependencies {
        String tomcatVersion = '7.0.47'
        tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}"
        tomcat "org.apache.tomcat.embed:tomcat-embed-logging-juli:${tomcatVersion}"
        tomcat("org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}") {
            exclude group: 'org.eclipse.jdt.core.compiler', module: 'ecj'
        }

        def cargoVersion = '1.3.3'
        cargo "org.codehaus.cargo:cargo-core-uberjar:$cargoVersion",
                "org.codehaus.cargo:cargo-ant:$cargoVersion"

        providedCompile 'javax.servlet:javax.servlet-api:3.1.0'
        providedCompile 'javax.websocket:javax.websocket-api:1.0'
        providedCompile 'javax.servlet:jsp-api:2.0'
        providedCompile "org.apache.tomcat:tomcat-servlet-api:${tomcatVersion}"
    }

    tomcatRun {
        dependsOn war
    }

    tomcatRunWar {
        dependsOn war
    }

    cargoRedeployRemote {
        dependsOn war
    }

    cargoDeployRemote {
        dependsOn war
    }

    cargo {
        containerId = 'tomcat7x'
        port = 8080

        deployable {
            context = "${project.name}"
        }

        remote {
            hostname = '192.168.13.209'
            username = 'ykyoon'
            password = 'qwer12#$'
        }
    }
}





Posted by Y2K
,

spock를 이용한 spring test

Java 2013. 12. 11. 03:17

드디어 JUnit의 시대가 끝이 나는것인가.... 라는 생각이 드는 멋진 테스트 Framework인 Spock를 소개합니다.


groovy 언어로 구성을 해야지 되는 언어적 제약사항이 있지만, 기존의 JUnit Test에 비해서 매우 간결한 문법과 TDD를 위한 구성을 제공하고 있습니다. 


1. 설정


gradle 기준으로 spock는 다음과 같이 설정됩니다. 


    repositories {
        mavenCentral()
        maven { url "http://oss.sonatype.org/content/repositories/snapshots/" }
     }
    dependencies {
        groovy "org.codehaus.groovy:groovy-all:2.1.5"
        testCompile "org.spockframework:spock-core:1.0-groovy-2.0-SNAPSHOT"
    }
   


기본적으로 JUnit을 사용하고 있는 test project입니다. 


2. 구성


기본적으로 JUnit과 거의 유사한 기본 설정을 갖습니다.

SpockJUnit
SpecificationTest class
setup()@Before
cleanup()@After
setupSpec()@BeforeClass
cleanupSpec()@AfterClass
FeatureTest
Parameterized featureTheory
ConditionAssertion
Exception condition@Test(expected=...)
@FailsWith@Test(expected=...)
InteractionMock expectation (EasyMock, JMock, ...)



3. 테스트 코드의 종류에 따른 구현


우리가 일반적으로 테스트 코드를 짤때, 다음과 테스트 코드가 주로 작성되게 됩니다. 


1) 특정한 method를 실행후, 객체의 상태가 예측대로인지 확인

2) 특정한 method의 input을 선정하고, output값이 예측대로 나왔는지 확인


이 두가지 case는 약간은 미묘하게 다릅니다. 1)의 경우는 response를 확인을 하는 것이고, 2)의 결과는 예측하는 것으로 약간의 차이를 가지고 오게 됩니다. 

이를 spock에서는 여러 block으로 지정하여, case에 대한 내용을 명확하게 하고 있습니다. 


spock에서 지원하는 block은 다음과 같습니다.  또한 이 block뒤에는 자유롭게 문자열을 넣어서, block안의 내용을 서술할 수 있습니다.


setup : 객체에 대한 설정

when / then : when block이 있으면 반드시 then block이 존재합니다. when에서 객체의 response를 얻어내고, then을 통해 response를 확인합니다. condition을 설정한다고 생각하시면 편합니다.

expect / where : 예측되는 값과 예측값 list를 정의합니다. spock에서 가장 놀라운 기능중 하나입니다. 


위 block을 적용한 code example은 다음과 같습니다. 


    def "건물이름 길이가 작아 exception이 발생"() {
        def buildingName = "가"
        when:
        service.searchByBuilding(buildingName)
        then:
        def e = thrown(IllegalArgumentException)
        e.message == AddressSearchServiceImpl.BUILDING_NAME_IS_TOO_SHORT
    }

    def "서울내의 레미안만 얻어오기"() {
        def buildingName = "레미안"
        when:
        def searchResults = service.searchByBuilding(SidoEnum.SEOUL.getStringValue(), buildingName, 0, 10)
        then:
        searchResults.every { r ->
            r.buildingName.contains(buildingName)
            r.siGunGu.sido.sidoNumber.equals(SidoEnum.SEOUL.getStringValue()) == true
        }
    }

    def "검색문자열 분리 테스트"() {
        expect:
        def matcher = SearchTextRegex.extractSearchText(input)
        matcher.find() == true
        matcher.group("mainText").equals(mainText) == true
        matcher.group("mainNumber").equals(mainNumber) == true

        where:
        input | mainText | mainNumber
        "구로동 245 번지" | "구로동" | "245"
        "서초1동" | "서초1동" | ""
        "잠실동 624" | "잠실동" | "624"
        "지봉로 1" | "지봉로" | "1"
    }

너무나 멋지지 않나요? 지금까지 구성하던 test code의 길이가 절반 이하로 줄어들뿐 아니라, 매우 직관적이고, TDD에 적합한 테스트 코드 형태로 구성이 되고 있는 것을 알 수 있습니다. 


개인적으로 하고 있는 project들을 모두 spock로 변경하는 작업을 한번 해봐야지 될 것 같습니다. 이런 것을 보면 왠지 희열이 느껴지지 않나요? ^^;;

모두들 Happy Coding~ 



Posted by Y2K
,

Spring 3.0에서 처음 도입된 Code Base Configuration은 이제 Spring 설정의 대세가 되어가는 기분이 든다. 

그런데, 이상하게도 Spring Security의 경우에는 Code Base Configuration이 적용되는 것이 매우 늦어지고 있다는 생각이 든다. 최신 3.2.0RC2에서의 Code Base Security를 구현하는 방법에 대해서 간단히 알아보기로 한다. 


1. @EnableWebSecurity Annotation

Spring Security를 사용하는 @Configuration은 @EnableWebSecurity annotation을 반드시 가져야지 된다.


2. WebSecurityConfigurerAdapter의 상속

@Configuration을 구성한 객체는 기본적으로 WebSecurityConfigurerAdapter를 상속받아야지 된다. 이 상속은 기본적인 method의 stub을 제공한다. 


@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

3. configurer 구현

configurer method를 override시켜서, configuration을 구성한다. 기본적으로 configuration xml에서 구성된 내용들은 configurer method에서 구현된다. 


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

기본적으로 구성할 수 있는 login form이다. /login에서 username, password를 이용해서 로그인 하게 되며, login success와 failure를 모두 처리하도록 되어있다. 

4. AuthenticationProvider의 구성

AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 반드시 구성해준다. AuthenticationProvider는 인증을 위한 사용자 Pool과 username, password간의 matching을 하는 방법을 제공한다.  주의점은 UserDetailsService이다. 반드시 BeanIds.USER_DETAILS_SERVICE bean name으로 등록이 되어야지 된다. Spring 내부에서 이 Bean Name을 이용해서 UserDetailsService를 구성하기 때문이다.


    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }


5. AuthenticationManager 구성

AuthenticationProvider를 이용해서 AuthenticationProvider를 구성해준다. 코드는 매우 단순하다. 다만 개인적으로는 Form Login 부분을 customizing을 시키는 경우가 많기 때문에 AuthenticationManager를 Bean으로 등록시켜두면 두고두고 편한 일들이 많다. 


    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


전체 코드는 다음과 같다. 


/**
 * Created with IntelliJ IDEA.
 * User: ykyoon
 * Date: 11/18/13
 * Time: 1:12 AM
 * To change this template use File | Settings | File Templates.
 */

@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

    @Autowired
    private ApplicationContext context;
    @Autowired
    private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;

    @Bean
    public DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
        DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter();
        digestAuthenticationFilter.setAuthenticationEntryPoint(corsEntryPoint);
        digestAuthenticationFilter.setUserDetailsService(userDetailsServiceBean());
        return digestAuthenticationFilter;
    }

    @Bean
    public LoginProcessHandler loginProcessHandler() {
        LoginProcessHandler loginProcessHandler = new LoginProcessHandler();
        loginProcessHandler.setObjectMapper(objectMapper());
        return loginProcessHandler;
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }

    @Bean(name = CORS_SUPPORT_FILTER)
    public Filter corsSupportFilter() {
        CorsSupportFilter corsSupportFilter = new CorsSupportFilter();
        return corsSupportFilter;
    }
}

6. no-web.xml인 경우 등록하는 방법


Servlet 3.0에서 지원되는 web.xml이 없는 개발에서 매우 유용한 방법이다. Spring Security는 Web.xml이 없을때, 아주 쉬운 코드로 WebXmlInitializer를 구성해뒀는데, AbstractSecurityWebApplicationInitializer를 상속받은 Class가 class path에 있는 경우, 자동으로 springSecurityFilterChain 이름의 Bean을 이용하는 DelegateProxyFilter를 등록한다. web.xml인 경우에는 기존과 동일하게 사용하면 된다. 다만 root context에 @Configuration이 로드가 먼저 선행되어야지 된다. 


public class SecurityWebXmlInitializer extends AbstractSecurityWebApplicationInitializer {
}


Posted by Y2K
,

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


Posted by Y2K
,

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




Posted by Y2K
,