일단 써보기 시작.




스프링 시큐리티를 이용해서 애플리케이션의 로그인처리를 하는 예제는 많이 있다. 거기에 OTP(One Time Password) 를 함께 사용하여 보안을 조금 더 강화해보도록 하겠다. 보통 TOTP(Time based One-time Password) 라고도 하는데 사용자에게 할당된 비밀키SecretKey와 OTP 킷에서 시간을 기준으로 생성하는 검증코드(Verification Code) 를 조합하여 인증가능한지 여부를 확인하는 것이다. 이때 사용자에게 할당되는 비밀키는 일회성으로 요청에 따라 새롭게 발급하는 식으로 운영이 된다. 

보통은 issuer + username + secretKey 를 이용하여 등록가능한 QR코드로 애플리케이션에 등록하는 형식을 취하게 된다.

QR리더를 이용해서 담겨있는 내용을 읽어보면 다음과 같다:

otpauth://totp/{issuer}:{username}?secret={secretKey}&issuer={issuer}

의 형식을 취한다. 이때 생성되는 secretKey는 일회성이다. 만약 외부에 노출될 경우에는 새롭게 secretKey를 발급받고 기존에 등록하 OTP 정보를 삭제하고 다시 등록해줘야 한다.

사용자에게 비밀번호를 변경하라(?)는 부담을 주지 않아도 된다. OTP 용 비밀키만 재발급하면 된다. 응?

2단계 인증과 관련한 내용은 구글의 2단계인증 과정에 대한 설명을 살펴보시면 더욱 좋을 듯 하다.

그럼 예제를 살펴보도록 하자.

spring-security-2step-verification 프로젝트

h2databse 는 기본구성이 메모리로 실행된다.

application.yml 설정

spring:
  datasource:
    initialize: true # data.sql 을 이용한 DB 초기화 작업
  mail:
    default-encoding: UTF-8
    username: #${username}
    password: #${password}
    host: smtp.gmail.com
    port: 587
    protocol: smtp
    properties:
      mail.smtp.starttls.enable: true
      mail.smtp.auth: true
  h2: # jdbc ur: jdbc:h2:mem:testdb
    console: # http://localhost:8080/h2-console
      enabled: true

OTP SecretKey 발급

UserServiceImpl
@Override
	public User insert(User user) {
		GoogleAuthenticatorKey key = googleAuthenticator.createCredentials();
		sendTOTPRegistrationMail(user, key);
		user.setOtpSecretKey(key.getKey());

		encodePassword(user);
		return repository.save(user);
	}

	private void sendTOTPRegistrationMail(User user, GoogleAuthenticatorKey key) {
		String qrCodeUrl = GoogleAuthenticatorQRGenerator.getOtpAuthURL(ISSUER, user.getUsername(), key);
		Map<String, Object> attributes = new HashMap<>();
		attributes.put("qrCodeUrl", qrCodeUrl);

		MailMessage mailMessage = MailMessage.builder()
				.templateName(MailMessage.OTP_REGISTRATION)
				.to(new String[]{user.getUsername()})
				.subject("Honeymon TOTP Registration mail")
				.attributes(attributes)
				.build();
		mailService.send(mailMessage);
	}

위의 코드를 보면 알겠지만 사용자 엔티티를 생성하는 순간에 인증키를 생성하여 secretKey를 사용자 엔티티에 할당하고, 그와 동시에 등록된 사용자 계정으로 메일을 발송한다.

사용자 계정생성 -> OTP 비밀키 생성 및 할당 -> OTP 등록 QR코드를 내장한 메일 발송 -> 사용자: OTP 디바이스에 등록 -> 로그인시 사용자명/비밀번호/OTP 검증코드

로그인때 입력하는 검증코드는 일반적으로 6자리를 사용하며 일정시간동안만 유효성을 가진다.

그 결과는 다음과 같으며,

이 QR코드를 Google OTP 앱에서 읽어들이면 다음과 같이 추가된다.


이제 OTP 코드는 일정간격으로 갱신된다. 


갱신되는 타이밍이 맞지 않으면 인증실패가 발생한다.

정상적으로 로그인하면 다음과 같은 화면을 볼 수 있다.


정리

2단계 인증은 비밀번호와 단말기(스마트폰, OTP 킷)가 있어야 로그인을 할 수 있다. 서버쪽에서 계정정보가 유출된다면, 바로 OTP용 비밀키를 초기화하고 사용자에게 재등록하도록 안내를 하면 계정이 도용되는 사례를 쉽게 막을 수 있는 장점을 제공한다.

2단계 인증을…​ 제대로 활용할 수 있다면 꽤 쓸만할 듯 하다.

참고문헌


타임리프의 확장은 쉽다. 방언(다이얼렉트,Dialect)를 생성하고 템플릿엔진에 추가하면 된다.

Dialect

타임리프 다이얼렉트는 템플릿에서 사용할 수 있는 기능이다.

다음과 같이 동작하는 다이얼렉트를 작성해보자.

<p hello:sayto="Jake">Hi ya!</p>

모든 다이얼렉트 는 IDialect 를 구현해야한다. 이를 용이하게 할 수 있도록AbstractDialect 을 이용한다. IProcessorDialect 는 실제로 Dialect 가

HelloDialect
public class HelloDialect extends AbstractDialect implements IProcessorDialect {
    public static final String PREFIX = "hello";
    public static final int PRECEDENCE = 501;
 
    public TimezoneDateDialect() {
        super("hello-dialect");
    }
 
    @Override
    public String getPrefix() {
        return PREFIX;
    }
 
    @Override
    public int getDialectProcessorPrecedence() {
        return PRECEDENCE;
    }
 
    @Override
    public Set<IProcessor> getProcessors(String dialectPrefix) {
        Set<IProcessor> iProcessors = Sets.newHashSet()
        iProcessors.add(new SayToProcessor(dialectPrefix)); (1)
        return iProcessors;
    }
}
뭔가~ 다른 것들을 추가할 수 있을 것 같지 않은가??

그럼 이제 이 다이얼렉트가 호출하여 실체 로직처리를 수행할 프로세스를 작성해보자.

SayToProcessor
public class SayToProcessor extends AbstractStandardExpressionAttributeTagProcessor {
 
    public static final String ATTR_NAME = "sayTo";
    public static final int PRECEDENCE = 1501;
 
    public SayToProcessor(String dialectPrefix) {
        super(TemplateMode.HTML, dialectPrefix, ATTR_NAMEPRECEDENCEtrue);
    }
 
    @Override
    protected void doProcess(ITemplateContext contextIProcessableElementTag tagAttributeName attributeNameString attributeValueObject expressionResultIElementTagStructureHandler structureHandler) {
        //TODO 비즈니스로직처리!! 
        structureHandler.setBody("Hello, " + expressionResult, false);
    }
}

작성한 다이얼렉트 를 템플릿엔진에 추가하자.

@Bean
public SpringTemplateEngine templateEngine() {
    SpringTemplateEngine engine = new SpringTemplateEngine();
    engine.setTemplateResolver(templateResolver());
    engine.setMessageSource(messageSource);
    engine.addDialect(new HelloDialect());
    return engine;
}

을 구현하고 나면!! 끝이 난다. 실제로 화면을 불러와 보면

<p>HelloJake!</p>

으로 출력되는 것을 볼 수 있을 것이다.


최근 팀 커뮤니케이션으로 많은 사람들의 사랑을 받고 있는 슬랙(Slack).

슬랙에서는 외부에서 슬랙채널에 메시지를 보낼 수 있는 WebHook API를 제공하고 있다. 웹훅은 슬랙으로 데이터를 보내는 Incoming WebHook 과 특정조건에 부합되었을 때 외부의 데이터를 가져오는 Outgoing WebHook 이 있다.

웹애플리케이션에서 슬랙채널로 메시지를 보내는 것은 Incoming WebHook을 이용하게 된다.

그러기 위해서는 우선 팀슬랙에 Incomming WebHook을 설정한다.

NOTE

Slack: Incoming WebHook 설정

작업을 진행하기에 앞서서 채널을 하나 개설한다. 그후 통합Integration 으로 이동하여 'incoming webhook' 을 검색하여 설치하고 채널을 지정한다. 필요하다면 아이콘을 변경하는 작업을 한다. 화면에 나오는 웹훅 URL 을 복사해둔다.


스프링부트 프로젝트 생성

스프링부트 프로젝트를 생성한다.


build.gradle
buildscript {
	ext {
		springBootVersion = '1.3.5.RELEASE'
	}
	repositories {
		mavenCentral()
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
	}
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'spring-boot'

jar {
	baseName = 'slack-incoming-webhook'
	version = '0.0.1-SNAPSHOT'
}
sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
	mavenCentral()
}


dependencies {
	compile('org.projectlombok:lombok:1.16.8')
	compile('org.springframework.boot:spring-boot-starter-web')
	compile('com.google.guava:guava:19.0')
	testCompile('org.springframework.boot:spring-boot-starter-test')
}

RestTemplate 빈 선언

WebConfiguration
package io.honeymon.springboot.slack.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class WebConfiguration {

	@Bean
	RestTemplate restTemplate() {
		return new RestTemplate();
	}
}

SlackNotifier 컴포넌트 생성

SlackNotifier
package io.honeymon.springboot.slack.integration;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import com.google.common.collect.Lists;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
 * Slack Notifier
 *
 * @author honeymon
 *
 */
@Slf4j
@Component
public class SlackNotifier {

	@Autowired
	private RestTemplate restTemplate;

	public enum SlackTarget {
		// TODO webHookUrl 은 자신의 슬랙 IncomingWebHookAPI로 변경하세요.
		CH_INCOMING("https://hooks.slack.com/services/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", "incoming");

		String webHookUrl;
		String channel;

		SlackTarget(String webHookUrl, String channel) {
			this.webHookUrl = webHookUrl;
			this.channel = channel;
		}
	}

	@Data
	@AllArgsConstructor
	@NoArgsConstructor
	@Builder
	public static class SlackMessageAttachement {
		private String color;
		private String pretext;
		private String title;
		private String title_link;
		private String text;
	}

	@Data
	@AllArgsConstructor
	@NoArgsConstructor
	@Builder
	public static class SlackMessage {
		private String text;
		private String channel;
		private List<SlackMessageAttachement> attachments;

		void addAttachment(SlackMessageAttachement attachement) {
			if (this.attachments == null) {
				this.attachments = Lists.newArrayList();
			}
			this.attachments.add(attachement);
		}
	}

	public boolean notify(SlackTarget target, SlackMessageAttachement message) {
		log.debug("Notify[target: {}, message: {}]", target, message);

		SlackMessage slackMessage = SlackMessage.builder().channel(target.channel)
				.attachments(Lists.newArrayList(message)).build();
		try {

			restTemplate.postForEntity(target.webHookUrl, slackMessage, String.class);
			return true;
		} catch (Exception e) {
			log.error("Occur Exception: {}", e);
			return false;
		}

	}
}

SlackController 생성

SlackSenderController
package io.honeymon.springboot.slack.web;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import io.honeymon.springboot.slack.integration.SlackNotifier;
import io.honeymon.springboot.slack.integration.SlackNotifier.SlackMessageAttachement;
import io.honeymon.springboot.slack.integration.SlackNotifier.SlackTarget;

@RestController
public class SlackSendController {
	@Autowired
	private SlackNotifier slackNotifier;

	@RequestMapping(value = "/", method = RequestMethod.POST)
	public ResponseEntity<Boolean> send(@RequestBody SlackMessageAttachement message) {  (1)
		return ResponseEntity.ok(slackNotifier.notify(SlackTarget.CH_INCOMING, message));
	}
}
  1. POST ` 방식으로 전송을 할 때 `@RequestBody 로 클래스를 정의하면 자동으로 매핑된다.

slack-incoming-webhook 실행

$ git clone https://github.com/ihoneymon/slack-incoming-webhook
$ cd slack-incoming-webhook
$ ./gradlew springboot

포스트맨을 이용한 실행 확인

실제 슬랙 화면


팀채널로 많이 사용하는 슬랙.

배포한 앱에서 중요한 사항(항상 상태를 체크해야하는 상황)에 대해서 슬랙 채널로 공지하도록 하는 기능을 간단하게 구현해봤다. @_@)> 생각보다 쉽다. 많이.

예제에서는 컨트롤러에서 요청을 받아서 처리하는 방식으로 구현했다.



인텔리제이 12버전인가를 사용했던 기억이 나는데...

4년만에 다시 사용하려고 하니 너무나 낯설다.

그렇다고 해서 이클립스를 잘 사용한 건 아니지만...

+ Recent posts