Spring boot ตอนที่ 3 – กรณีศึกษา MyFacebook web (1)

spring-boot-logo

เพื่อให้เห็นภาพการพัฒนาเว็บ ผมจึงตั้งโจทย์ว่าผมจะทำเว็บคล้ายๆ กับ facebook ในแบบของตัวเอง เอาแค่สามอย่างพอ

  1. ลงทะเบียน (Register)
  2. โพสต์/คอมเม้นต์ (Post/Comment)
  3. ส่งคำขอและยกเลิกเป็นเพื่อน (Add friend/Unfriend)

ในบทนี้จะเป็นการหัดใช้

  • Spring security เพื่อตรวจสอบสิทธิการใช้งานเว็บ
  • Bootstrap สำหรับจัดการ layout หน้าเว็บ


updated#1
รวมลิงก์ของทุกตอนครับ

updated#2
ตอนนี้ผมกำลังทำ video ลง youtube อยู่ครับ

Spring boot basic security

ก่อนจะเริ่มเขียนหน้า login ขอแนะนำ security เบื้องต้นที่ spring boot เตรียมไว้ให้ก่อน ถ้ายังจำได้เรามี config ใน application.properties ที่ชื่อ security.basic.enabled ลองเปลี่ยนเป็น true แล้ว start spring boot ดูครับ จากนั้นเข้าหน้าเว็บ Hello world เราก็จะพบกับ dialog ให้ login

1-basic-sec

ค่า default ของ username คือ user ส่วน password จะเป็นการ auto generate ตอน start spring boot ให้กลับไปดูใน log แล้ว copy มาก็สามารถเข้าหน้าเว็บได้ตามปกติครับ

2-default-pass

เราสามารถตั้งค่าแบบตายตัวได้ว่าอยากได้แบบใหน โดย config ใน application.properties ดังนี้

security.basic.enabled=true
security.user.name=admin
security.user.password=password

Spring security

จากตัวอย่าง basic security จะเห็นได้ว่ามันรองรับแค่ผู้ใช้งานคนเดียว หากเราต้องการให้รองรับหลายคนแล้วจะให้ใช้แค่ account เดียวคงไม่ตอบโจทย์
เราจึงต้องพึ่ง Spring security ในการตรวจสอบว่าหากยังไม่มีสิทธิเราจะไม่ให้เข้าใช้งาน ซึ่งจะต้อง login ก่อนเท่านั้น

  1. กลับไปตั้งค่า security.basic.enabled=false ก่อน
  2. สร้างคลาส SecurityConfig และโค้ดดังนี้
    package com.magicalcyber.myfacebook;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		http.authorizeRequests().anyRequest().authenticated();
    	}
    
    }
    
    
    

    ความหมายคือผมต้องการให้ทุก request ที่เข้ามาจะต้องมีสิทธิเข้าใช้งานเท่านั้น จากนั้น start spring boot และเมื่อเข้าหน้า http://localhost:8080/hello ก็จะพบกับหน้าจอ error ที่บอกว่าไม่มีสิทธิเข้า
    3-first-spring-sec

    จากตัวอย่าง basic security เรามี account default แต่ทว่าเรายังไม่มี แถมยังไม่มีที่ให้ login ด้วย ผมจึงไปปรับ security config ใหม่ดังนี้

    package com.magicalcyber.myfacebook;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		http.authorizeRequests().anyRequest().authenticated().and().formLogin();
    	}
    
    	@Override
    	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    		auth.inMemoryAuthentication().withUser("admin").password("password").roles("ADMIN");
    	}
    }
    
    

    ผมเพิ่ม config ไว้ว่าให้สร้าง admin ไว้บน memory ตอน start server จากนั้นก็ start spring boot แล้วเข้าไปที่ http://localhost:8080/hello อีกครั้งก็จะพบหน้าจอ default ที่สวยงาม (มั้ง) ที่ผมไม่ได้เขียนเอง
    4-login

    พอ login เข้าไปด้วย username/password ที่เราตั้งไว้ใน SecurityConfig ก็จะเข้าหน้า Hello world ได้ตามปกติ

  3. เนื่องจากว่าหน้าจอ login ที่ spring แถมมาให้มันไม่สวยตามที่ผมต้องการ ผมจึงต้องทำหน้า login ใหม่ดังนี้ โดยตั้งชื่อว่า login.jsp และมีหน้าตาดังนี้

    <%@ page language="java" contentType="text/html; charset=UTF-8"
    	pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    
    <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Login</title>
    </head>
    <body>
    	<form action='<c:url value="/login" />' method="POST">
    		<div>
    			<label for="username">Username</label>
    			<input type="text" id="username" name="username" />
    		</div>
    
    		<div>
    			<label for="password">Password</label>
    			<input type="text" id="password" name="password" />
    		</div>
    
    		<div>
    			<input type="submit" value="Login" />
    		</div>
    	</form>
    </body>
    </html>
    

    จากนั้นเปิดคลาส SecurityConfig เพื่อตั้งค่าให้ไปเรียกหน้า login ของเราดังนี้

    package com.magicalcyber.myfacebook;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		http.authorizeRequests().antMatchers("/login").permitAll().anyRequest().authenticated().and().formLogin()
    				.loginPage("/login").defaultSuccessUrl("/hello");
    	}
    
    	@Override
    	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    		auth.inMemoryAuthentication().withUser("admin").password("password").roles("ADMIN");
    	}
    }
    
    

    สร้างคลาส LoginController เพือเรียกหน้า login ขึ้นมา

    package com.magicalcyber.myfacebook.web.controller;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class LoginController {
    
    	@GetMapping("/login")
    	String login() {
    		return "login";
    	}
    }
    
    

    เมื่อ start spring boot แล้วเข้าไปยังหน้า hello ก็จะปรากฏหน้าจอ custom login ของเราดังนี้
    5-login-custom-failed

    เหมือนสมัยหัดทำเว็บใหม่ๆ เลย
    มันไม่ได้สวยขึ้นเลย!! ของเก่ายังดีกว่าอีก

  4. ซึ่งผมก็จะเอา Bootstrap ซึ่งเป็น css สำเร็จรูปมาใช้งาน ผมสร้างโฟลเดอร์ css ไว้ใน src/main/resources/static แล้วก้อป bootstrap.min.css มาวาง
    6-bootstrap

    จากนั้นไปที่ SecurityConfig ไปตั้งค่าเพื่อให้ css สามารถเข้าถึงได้จากทุกคนถึงแม้จะไม่มีสิทธิ

    package com.magicalcyber.myfacebook;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.annotation.web.builders.WebSecurity;
    import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
    import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
    
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    	@Override
    	public void configure(WebSecurity web) throws Exception {
    		web.ignoring().antMatchers("/css/**");
    	}
    
    	@Override
    	protected void configure(HttpSecurity http) throws Exception {
    		http.authorizeRequests().antMatchers("/login").permitAll().anyRequest().authenticated().and().formLogin()
    				.loginPage("/login").defaultSuccessUrl("/hello");
    	}
    
    	@Override
    	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    		auth.inMemoryAuthentication().withUser("admin").password("password").roles("ADMIN");
    	}
    }
    
    
    

    จากนั้นก็ปรับหน้าจอตามความต้องการ โดยยึดวิธีทำตามเว็บ http://getbootstrap.com/css/

    <%@ page language="java" contentType="text/html; charset=UTF-8"
    	pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
    	content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    
    <title>Login</title>
    <link rel="stylesheet" type="text/css"
    	href='<c:url value="/css/bootstrap.min.css" />' />
    </head>
    <body>
    	<div class="container">
    		<div class="row">
    			<div class="col-md-6 col-md-offset-3">
    				<form class="form" action='<c:url value="/login" />' method="POST">
    					<div class="form-group">
    						<label for="username">Username</label>
    						<input type="text" class="form-control" id="username"
    							name="username" />
    					</div>
    
    					<div class="form-group">
    						<label for="password">Password</label>
    						<input type="password" class="form-control" id="password"
    							name="password" />
    					</div>
    
    					<div>
    						<input type="submit" class="btn btn-success" value="Login" />
    					</div>
    				</form>
    			</div>
    		</div>
    	</div>
    
    </body>
    </html>
    

    ทดสอบหน้าเว็บใหม่อีกครั้ง คราวนี้ผมว่าสวยกว่าเมื่อก่อนเยอะหล่ะ ที่เหลือก็แต่งกันตามใจชอบนะครับ
    4-new-login

  5. ลอง login อีกครั้ง คราวนี้จะพบกับหน้าจอ error เกี่ยวกับ CSRF ดังนี้
    7-csrf

    ผมขอไม่อธิบาย เพราะมีคนอธิบายไว้แล้ว เช่นในเว็บ blognone ตามไปอ่านกันได้เลยครับ ส่วนเรื่องนี้ผมจะทำให้ form ผมป้องกัน CSRF ได้โดยการเพิ่ม tag ของ spring form เข้าไป เพื่อให้ form ของผม generate _csrf ที่ spring security ต้องการดังนี้

    <%@ page language="java" contentType="text/html; charset=UTF-8"
    	pageEncoding="UTF-8"%>
    <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
    <%@taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
    
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta name="viewport"
    	content="width=device-width, initial-scale=1, maximum-scale=1" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    
    <title>Login</title>
    <link rel="stylesheet" type="text/css"
    	href='<c:url value="/css/bootstrap.min.css" />' />
    </head>
    <body>
    	<div class="container">
    		<div class="row">
    			<div class="col-md-6 col-md-offset-3">
    				<form:form cssClass="form" action="/login" method="POST">
    					<div class="form-group">
    						<label for="username">Username</label>
    						<input type="text" class="form-control" id="username"
    							name="username" />
    					</div>
    
    					<div class="form-group">
    						<label for="password">Password</label>
    						<input type="password" class="form-control" id="password"
    							name="password" />
    					</div>
    
    					<div>
    						<input type="submit" class="btn btn-success" value="Login" />
    					</div>
    				</form:form>
    			</div>
    		</div>
    	</div>
    
    </body>
    </html>
    

    ปกติที่ผมเจอคนอื่นแก้ปัญหานี้คือไปปิด csrf ใน SecurityConfig ซึ่งผมไม่แนะนำเป็นอย่างยิ่ง เพราะมันเป็นเรื่อง security
    จากนั้นเมื่อ login อีกครั้งก็จะพบกับหน้า hello world แล้ว

Tests

กลับไปรันเทสตัวเดิมที่เราคยรันผ่าน ตอนนี้มันควรจะไม่ผ่านครับ เนื่องจากติด security มัน redirect ไปหน้า login นี่คือข้อดีของการทำ Test ครับ คือเราสามารถรู้ได้ว่าของเดิมยังทำงานได้อยู่หรือเปล่าเมื่อมีการแก้ไข
8-test-failed

วิธีทดสอบ spring security ก็คือสมมุติเอาว่าเรามีสิทธิในการเข้าใช้งานครับโดยใช้ @WithMockUser กรณีที่ไม่มีสิทธิก็ใช้ @WithAnonymousUser ซึ่งเมื่อพิมพ์ไปแล้วกด ctrl+space ตัว STS จะทำการเพิ่ม library ตัวใหม่แล้วดาวโหลดให้เราด้วย
9-new-dep

โดย test ล่าสุดเป็นดังนี้

package com.magicalcyber.myfacebook.web.controller;

import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithAnonymousUser;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class HelloControllerTest {

	private final String ROLE_ADMIN = "ADMIN";

	@Autowired
	private WebApplicationContext wac;

	private MockMvc mockMvc;

	@Before
	public void setup() {
		mockMvc = MockMvcBuilders.webAppContextSetup(wac).apply(springSecurity()).build();
	}

	@Test
	@WithAnonymousUser
	public void hello_withoutPermission_mustLoginPage() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.TEXT_HTML))
				.andExpect(status().is3xxRedirection());
	}

	@Test
	@WithMockUser(roles = ROLE_ADMIN)
	public void hello_withoutName_mustDefaultWorld() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.TEXT_HTML)).andExpect(status().isOk())
				.andExpect(model().attribute("name", Matchers.equalTo("World")));
	}

	@Test
	@WithMockUser(roles = ROLE_ADMIN)
	public void hello_withName_mustEqualsParameter() throws Exception {
		String name = "MagicKiat";

		mockMvc.perform(MockMvcRequestBuilders.get("/hello?name=" + name).accept(MediaType.TEXT_HTML))
				.andExpect(status().isOk()).andExpect(model().attribute("name", Matchers.equalTo(name)));

	}

}

กรณีเทสของผู้ใช้งานที่ไม่มีสิทธิ อาจจะ test แบบอื่นได้ อย่างเช่นถ้ามีหน้าลงทะเบียน ผู้ใช้คนนั้นก็จะต้องเข้าไปลงทะเบียนได้ จากนั้นก็รันเทสอีกครั้ง คราวนี้ควรจะผ่านฉลุย
10-test-passed

บทต่อไปผมจะพาทำหน้าลงทะเบียนและจะกลับมาทำเทสใหม่อีกครั้งครับ ส่วนโค้ดทั้งหมดเดี๋ยวผมจะอัพขึ้น github เร็วๆ นี้ครับ

Sourcecode

https://github.com/magickiat/myfacebook.git

Advertisements

5 thoughts on “Spring boot ตอนที่ 3 – กรณีศึกษา MyFacebook web (1)

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s