본문 바로가기

Programming/Spring Security

[Spring Security][회원가입 및 로그인 예제 7/9] Controller 구현

들어가며...

이번 글에서는 Controller쪽 코드를 살펴보도록 하겠습니다. 

샘플 화면이다 보니 회원가입 부분을 빼고는 대부분 요청 파일 패스와 실제 html파일을 연결하는 정도가 전부이며 HTTP error가 발생했을 경우 Browser에 기본 탑재된 에러 UI가 아닌 customizing하는 방법에 대해서도 알아보도록 하겠습니다.

 

  • Table of Contents
    • Controller 구현
    • ErrorController 구현

Controller 구현

● MemberController.java

package com.demo.security.controller;

import javax.annotation.Resource;
import javax.validation.Valid;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import com.demo.security.auth.UserService;
import com.demo.security.auth.model.Account;

@Controller
public class MemberController {
	protected Logger log = LoggerFactory.getLogger(this.getClass());
	@Resource(name = "userServiceImpl")
	private UserService userService;
	
	/**
	 * initial
	 */
	@RequestMapping(value = {"/", "/login"}, method = {RequestMethod.GET, RequestMethod.POST})
	public String login(Model model) {
		return "/auth/login";
	}
	
	/**
	 * Registration view
	 */
	@RequestMapping(value = "/registration", method = RequestMethod.GET)
	public String registration(Model model) {
		model.addAttribute("account", new Account());
		return "/auth/registration";
	}
	
	/**
	 * Registration form
	 */
	@RequestMapping(value = "/registration", method = RequestMethod.POST)
	public String createNewUser(Model model, @Valid Account account, BindingResult bindingResult) {
		try {
			//< check the user name already exist or not
			Account userExists = userService.getUserByUsername(account.getUsername());
			if(userExists != null) {
				bindingResult.rejectValue("username", "error.user", "There is already a user registered with the user name provided");
			}
			
			//< check the password
			if(!account.getPassword().equals(account.getConfirmPassword())) {
				bindingResult.rejectValue("confirmPassword", "error.user", "Password not matched");
			}
			
			if(bindingResult.hasErrors()) {
				log.error("[ykson] : " + bindingResult.getFieldError().toString());
			}
			else {
				//< save the user information
				userService.setUser(account);
				
				//< set the user information
				model.addAttribute("user", new Account());
				model.addAttribute("successMessage", "User has been registered successfully");
			}
		} catch (Exception e) {
			log.error(e.getMessage());
			model.addAttribute("successMessage", "FAIL : " + e.getMessage());
		}
		
		return "/auth/registration";
	}
	
	/**
	 * Common home
	 */
	@RequestMapping(value = "/home", method = RequestMethod.GET)
	public String home(Model model) {
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		Account account = null;
		try {
			account = userService.getUserByUsername(auth.getName());
		} catch (Exception e) {
			log.error("[ykson]" + e.getMessage());
		}
		
		model.addAttribute("username", "" + account.getUsername() + "(" + account.getEmail() + ")");
		model.addAttribute("adminMessage", "Content Available Only for Users with Admin Role");
		
		return "/index";
	}
	
	/**
	 * Administration Home
	 */
	@RequestMapping(value = "/home/admin", method = RequestMethod.GET)
	public String adminHome(Model model) {
		return "/home/admin";
	}
	
	/**
	 * User Home
	 */
	@RequestMapping(value = "/home/user", method = RequestMethod.GET)
	public String userHome(Model model) {
		return "/home/user";
	}
	
	/**
	 * Guest Home
	 */
	@RequestMapping(value = "/home/guest", method = RequestMethod.GET)
	public String guestHome(Model model) {
		return "/home/guest";
	}
}

 

처음에 언급하였듯이 회원가입 (registration, POST방식)을 제외하곤 특이한 사항이 없습니다. 간략하게 몇가지 참고할 사항에 대해서 정리해보도록 하겠습니다.


● View를 반환하는 방법

Controller에서 View(html 페이지)를 반환하는 방법은 ModelAndView 객체를 선언하여 각종 파라미터와 반환될 html을 설정하여 반환하는 방법과 위에 구현한 방식처럼 입력 파라미터에 Model객체를 전달 받아 해당 객체를 이용하여 반환할 파라미터 설정 후 반환 값으로 view이름을 정의하는 방법입니다.

ModelAndView 반환방식이 Model 객체를 이용하는 것보다 오래전에 나온 방식이라는 점과 약간의 사용법이 다른 것을 제외하곤 본인의 취향에 맞는 것을 사용하여도 무방합니다.

 

▶ ModelAndView 방식

/**
* initial
*/
@RequestMapping(value = {"/", "/login"}, method = {RequestMethod.GET, RequestMethod.POST})
public ModelAndView login() {
	ModelAndView mav = new ModelAndView();
	mav.setViewName("/auth/login");

	return mav;
}

● Form Validation

Form 기반 데이터들은 보통 database에 저장되는 경우가 많습니다. 따라서 유효한 값 여부를 반드시 체크하여야 합니다. UI자체적으로 Validation 검사를 수행하고 Back End System에서도 중복 체크를 수행하는 것이 좋습니다.

위 코드를 보시면 회원가입 시 Validation 체크하는 부분이 있습니다. 이번 프로젝트에서는 간략하게 Validation 체크할 대상 Object에 어노테이션을 이용하여 체크하는 부분과 일부 항목에 대해서는 BindingResult를 이용하여 Reject message를 전달하는 방법을 구현하여 보았습니다.

 

위 방법 외에도 좀 더 세밀하게 입력값을 검증하는 방법으로는 인터페이스 "ConstraintValidator"를 구현하는 방법이 있습니다. (아마도 실전에서는 해당 방법을 가장 많이 사용하게 될 것입니다.)

본 글에서는 이 중 어노테이션을 이용하여 Validation하는 방법에 대해서 간략하게 알아보도록 하겠습니다.

 

▶ Controller에서 검증하고자 하는 객체에 대해 @Valid 어노테이션을 추가

@RequestMapping(value = "/registration", method = RequestMethod.POST)
public String createNewUser(Model model, @Valid Account account, BindingResult bindingResult) {
	/* 중략 */
}

 

▶ @Valid 어노테이션이 붙은 Object 클래스에 필요한 검증 어노테이션 추가

@Entity
@Table(name = "account", uniqueConstraints = {@UniqueConstraint(name = "NAME_EMAIL_UNIQUE", columnNames = {"USERNAME", "EMAIL"})})
public class Account {
	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private Long id;
	@Column(nullable = false)
	@NotBlank
	@Length(min = 4)
	private String username;
	@Column(nullable = false)
	@NotBlank
	@Length(min = 4)
	private String password;
	@Transient
	@NotBlank
	private String confirmPassword;
	@Column(nullable = false)
	@NotBlank
	@Email
	private String email;
	@Column(nullable = false)
	private Boolean isActive;
	@CreationTimestamp
	private Date regDate;
	@ManyToMany(cascade = CascadeType.MERGE)
	@JoinTable(name = "user_role", 
		joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"), 
		inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"))
	private Set<Role> roles;
 
	/* 중략 */
}

 

가장 처음에 설명하였던 Account Model 코드에서 기존에는 JPA 어노테이션에 대해서만 설명하였지만 해당 어노테이션외에도 @NotBlank, @Length, @Email 등의 어노테이션은 검증 어노테이션입니다.

엔티티관련 어노테이션에 Validation 어노테이션까지 있어 헷갈리고 복잡합니다. 실전에서는 입력단의 오브젝트와  엔티티를 위한 오브젝트를 분리하거나 ConstraintValidator를 구현하는 방법이 좋을 것 입니다.

 

▶ 지원하는 어노테이션 목록

https://javaee.github.io/javaee-spec/javadocs/javax/validation/constraints/package-summary.html

 

javax.validation.constraints (Java(TM) EE 8 Specification APIs)

Enum Summary  Enum Description Pattern.Flag Possible Regexp flags. Annotation Types Summary  Annotation Type Description AssertFalse The annotated element must be false. AssertFalse.List Defines several AssertFalse annotations on the same element. AssertTr

javaee.github.io

ErrorController 구현

웹 어플리케이션을 구현하다 보면 잘못된 주소를 요청하거나 권한이 없는 페이지 요청등 다양한 HTTP Error가 발생할 수 있습니다. 해당 페이지들은 별도로 커스터마이징을 하지 않으면 WAS에 포함되어 있는 디폴트 페이지를 보여주게 됩니다.

 

● 디폴트 HTTP Error 페이지 예시

보편적으로 해당 페이지 UI가 심플하기 때문에 해당 사이트의 UI와 동일한 디자인으로 자체 제작하게 됩니다. 이러한 사용자 커스터마이징 하는 방법에 대해서 알아보면 다음과 같습니다.

● classpath 루트 디렉토리에 error.html 작성하는 방법

Spring boot project 기준으로 "src/main/resources/templates" 폴더에 자체 제작한 error.html을 두면 HTTP error 발생 시 해당 페이지를 보여주게 됩니다. 단점이라면 모든 에러에 대해서 동일한 페이지를 보여준다는 것일 것입니다.

● 인터페이스 ErrorController를 구현하는 방법

ErrorController 인터페이스를 implements하면 "public String getErrorPath()"라는 함수를 구현하여야 하는데 이곳에 에러처리를 할 파일 패스를 반환하고 해당 파일패스에 대한 RequestMapping을 구현하여 HTTP 에러 페이지를 커스터마이징하는 방법입니다.

 

▶ CustomErrorController.java

package com.demo.security.controller;

import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class CustomErrorControler implements ErrorController {
	protected Logger log = LoggerFactory.getLogger(this.getClass());
	private final String DEFAULT_ERROR_PATH = "/error";
	
	////////////////////////////////////////////////////////////////////////////////
	//< public functions

	@Override
	public String getErrorPath() {
		return DEFAULT_ERROR_PATH;
	}
	
	@RequestMapping("/error")
	public String errorHandle(HttpServletRequest request, Model model) {
		return errorHandleImpl(request, model);
	}
	
	
	/**
	 * Access denied
	 */
	@RequestMapping(value = "/access-denied", method = RequestMethod.GET)
	public String accessDenied(Model model) {
		//< set the attributes
		model.addAttribute("errorCode", "403");
		model.addAttribute("errorMessage", "Forbidden");
		
		return getErrorPath() + "/error";
	}
	
	////////////////////////////////////////////////////////////////////////////////
	//< private functions
	
	/**
	 * Implement a error page
	 */
	private String errorHandleImpl(HttpServletRequest request, Model model) {
		//< get the status code
		Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
		//< get the HTTP status
		HttpStatus httpStatus = HttpStatus.valueOf(Integer.valueOf(status.toString()));
		
		//< set the attributes
		model.addAttribute("errorCode", status.toString());
		model.addAttribute("errorMessage", httpStatus.getReasonPhrase());
		
		return getErrorPath() + "/error";
	}
}

 

errorHandleImpl 함수를 보면 HttpServletRequest에서 에러가 발생한 에러코드와 이유를 가져와 반환할 페이지의 어트리뷰트로 반환하고 있습니다. 모든 HTTP error에 대해 하나의 페이지에서 처리하기 위해 간단하게 구현하였으나 실제 작업하실 때는 필요한 HTTP Error 코드별로 에러페이지를 구현할 수도 있을 것입니다.

 

추가적으로 "/access-denied" RequestMapping은 WebSecurityConfigurerAdapter 구현 시 접근 불가 페이지에 대한 경로를 설정한 것으로 반환되는 페이지는 동일합니다.

 

▶ SecurityConfig.java

@Override
protected void configure(HttpSecurity http) throws Exception {
	http.authorizeRequests()
	/* 중략 */
		.exceptionHandling()
			.accessDeniedPage("/access-denied");
}

마무리...

지금까지 Controller 구현에 대해서 알아보았습니다. HTTP Error 페이지를 커스터마이징하는 것 외엔 크게 특별한 것이 없으며 Form Validation에 대한 설명이 조금 미흡한데 기회가 되면 Front-End, Back-End의 Form Validation에 대해서 자세하게 알아보는 시간을 가져보도록 하겠습니다.

 

다음 글에서는 View단에 대해 간단하게 코드만 살펴보도록 하겠습니다.

 


U2ful은 입니다. @U2ful Corp.