Spring Security + JWT + JPA + Springdoc 實例解析

Photo by Ivo Rainha on Unsplash
Photo by Ivo Rainha on Unsplash
本章將介紹在 Spring Security 下,如何實作 JWT-Token-Based 驗證的方法。我們將解釋如何從 JPA 讀取 Username/Password 來做登入驗證,如何產生 JWT Token,以及如何驗證 JWT Token。

本章將介紹在 Spring Security 下,如何實作 JWT-Token-Based 驗證的方法。我們將解釋如何從 JPA 讀取 Username/Password 來做登入驗證,如何產生 JWT Token,以及如何驗證 JWT Token。最後,展示如何在 Swagger 中整合 Token 驗證。

完整程式碼可以在 下載。

JWT-Token-Based Authentication

近幾年來,前端不再是只有網頁,還包含了手機(Android/iOS)。在早期,網頁前端的程式碼都是後端輸出的,如 PHP、ASP、JSP。在這些架構下,前後端是利用 Cookie 來紀錄 Session ID。而這 Session ID 也就會被用來檢驗是否已經登入,這樣的流程稱為 Form Login。Spring Security 當然有支援 Form Login 驗證,可以參考以下這篇文章。

然而,Form Login 這樣的驗證流程,對於手機端來說並不好實作,原因是 Cookie。手機端不使用 Cookie,也不需要後端輸出程式碼,只要資料。因此 RESl APIs 對手機端是相當友好的串接方式。在近期,前端也發展出 Single Page Application (SPA) 架構。SPA 可以使用 Cookie,但不需要後端輸出程式碼,只要資料。所以,SPA 和 RESTf APIs 也一起運作的相當良好。

為了要滿足手機端的需求,後端必須要提供一種不使用 Cookie 的驗證方式。其中一種就是基於 Token 的驗證方式。簡單來說,就是將放在 Cookie 的東西,改放在 HTTP Request Header 裡。Spring Security 提供 Basic AuthenticationDigest Authentication。這兩者的流程和 Form Login 一樣都是 Username/Password Authentication,但是是基於 Token 的驗證方式。而本章要介紹的 JWT-Token-Based Authentication 也是 Username/Password Authentication,但是使用 JSON Web Token (JWT) 作為 Token 的格式。

JWT-Token-Based Authentication 流程

在開始動手寫程式之前,先概略地了解一下,我們要實作的流程,如下圖所示:

Spring Security + JWT Architecture
Spring Security + JWT Architecture

若對圖中的 FilterChain、SecurityFilterChain、ExceptionTranslatorFilter、FilterSecurityInterceptor 不熟悉的話,請先閱讀下面的文章,不然無法理解此圖。

想要在 Spring Security 中實現我們的流程,構想是在 SecurityFilterChain 中插入一個 JwtFilter。它會從 HTTP Request 的 Header 中,取得 JWT Token。然後,驗證這個 JWT Token 是不是合法的 JWT Token。最後,將這 JWT Token 轉成 Authentication 物件,並設定到 SecurityContextHolder,這就表示這個 HTTP Request 或 Session 是有驗證過的。

若是,在 Header 中沒有 JWT Token,或是 JWT Token 是不合法的,那在 FilterSecurityInterceptor 裡就會丟出 Exception,ExceptionTranslationFilter 就會來處理這個 Exception。在原本的流程中,authenticationEntryPoint.commence() 和 accessDeniedHandler.handle() 都會做重新導向的處理。但,我們只希望它們回傳錯誤訊息就好,所以必須要重新實作這兩個部分。

若進來的是 POST /login 的話,它會通過 JwtFilter,也會通過 ExceptionTranslatorFilter 和 FilterSecurityInterceptor,因為我們會設定 /login 不需要權限。所以,最終會到 LoginController.login()。

LoginController.login() 會驗證使用者,然後回傳一個 JWT Token。之後,前端就可以用這個 JWT Token 來存取後端的 API。

流程不算複雜,接下來,我們來看實作的部分。

JWT-Token-Based Authentication 專案

在這專案中,我們從 JPA 讀出使用者資料來做驗證。在 JWT 的部分,不會做深入的解釋。

建立專案

建立一個 Spring Boot 專案,並引入圖中四個 Dependencies。

Spring Security JWT Project
Spring Security JWT Project

如果不知道要怎麼建立專案,或不了解上圖的話,可以先參考下面這篇文章。

建立資料庫

新增 Member Entity,包含 usernamepassword、和 authorities

package com.waynestalk.jwtexample.model

import javax.persistence.*

@Entity
data class Member(
        @Column(unique = true) val username: String,
        val password: String,
        val name: String,
        @ElementCollection val authorities: Collection<String>,
        @Id @GeneratedValue var id: Long? = null
)

新增 MemberRepository 來讀取資料庫。

package com.waynestalk.jwtexample.repository

import com.waynestalk.jwtexample.model.Member
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query

interface MemberRepository : JpaRepository<Member, Long> {
    @Query("SELECT m FROM Member m JOIN FETCH m.authorities WHERE m.username = (:username)")
    fun findByUsername(username: String): Member?
}

為了方便稍後的測試,我們這邊先預先創建幾個 Member 到資料庫裡。注意只有 Monika 才有 ADMIN 權限。

package com.waynestalk.jwtexample.config

import com.waynestalk.jwtexample.repository.MemberRepository
import com.waynestalk.jwtexample.model.Member
import org.springframework.boot.ApplicationRunner
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.password.PasswordEncoder

@Configuration
class MemberConfiguration {
    @Bean
    fun initMembers(memberRepository: MemberRepository, passwordEncoder: PasswordEncoder) = ApplicationRunner {
        memberRepository.saveAll(listOf(
                Member("monika", passwordEncoder.encode("123456"), "Monika", listOf("ROLE_ADMIN", "ROLE_USER")),
                Member("jack", passwordEncoder.encode("123456"), "Jack", listOf("ROLE_USER")),
                Member("peter", "123456", "Peter", listOf("ROLE_USER"))
        ))
    }
}

建立 JwtTokenProvider

新增 JwtTokenProvider,它提供三個方法 – 產生 JWT Token、驗證 JWT Token、以及轉換 JWT Token 成 Authentication 物件。

package com.waynestalk.jwtexample.auth

import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import org.slf4j.LoggerFactory
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.stereotype.Component
import java.util.*

@Component
class JwtTokenProvider {
    companion object {
        const val claimAuthorities = "authorities"
    }

    private val logger = LoggerFactory.getLogger(this::class.java)
    private val secret = "qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q=="
    private val key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))

    fun generate(authentication: Authentication): String {
        val authorities = authentication.authorities?.joinToString { it.authority } ?: ""
        val expiration = Date(System.currentTimeMillis() + (60 * 60 * 1000))
        return Jwts.builder()
                .setSubject(authentication.name)
                .claim(claimAuthorities, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(expiration)
                .compact()
    }

    fun toAuthentication(token: String): Authentication {
        val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()
        val claims = jwtParser.parseClaimsJws(token).body
        val authorities = claims[claimAuthorities].toString().split(",").map { SimpleGrantedAuthority(it) }
        val user = User(claims.subject, "", authorities)
        return UsernamePasswordAuthenticationToken(user, token, authorities)
    }

    fun validate(token: String): Boolean {
        val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()

        try {
            jwtParser.parse(token)
            return true
        } catch (e: Exception) {
            logger.error(e.message)
        }

        return false
    }
}

程式碼中的 secret 可以用下面的指令來產生:

% openssl rand -base64 172
qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q==

generate(),我們將到期時間放入 JWT Token 裡,讓 Token 有時效性。

在 toAuthentication(),建立 UsernamePasswordAuthentication 時,其 Credentials 的部分就放入 Token 本身,不需要再從資料庫中取出密碼放入。

仔細看一下,generate() 並沒有儲存產生出來的 JWT Token。那 validate() 要怎麼驗證呢?這就是 JWT 強大的地方。簡單來說,整個 JWT Token 包含了明文和明文的加密字串。只有後端擁有加密的 Secret,所以將 JWT Token 的明文部分加密後,和 JWT Token 的加密字串部分,相比較就可以了。此外,明文裡還包含著到期日期(Expiration),過期的 Token 當然也會驗證不通過。

建立 JwtFilter

新增 JwtFilter,程式碼如下:

package com.waynestalk.jwtexample.auth

import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.util.StringUtils
import org.springframework.web.filter.GenericFilterBean
import javax.servlet.FilterChain
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.HttpServletRequest

class JwtFilter(private val jwtTokenProvider: JwtTokenProvider) : GenericFilterBean() {
    companion object {
        const val authenticationHeader = "Authorization"
        const val authenticationScheme = "Bearer"
    }

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val token = extractToken(request as HttpServletRequest)
        if (StringUtils.hasText(token) && jwtTokenProvider.validate(token)) {
            SecurityContextHolder.getContext().authentication = jwtTokenProvider.toAuthentication(token)
        }

        chain.doFilter(request, response)
    }

    private fun extractToken(request: HttpServletRequest): String {
        val bearerToken = request.getHeader(authenticationHeader)
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("$authenticationScheme ")) {
            return bearerToken.substring(authenticationScheme.length + 1)
        }
        return ""
    }
}

JwtFilter 嘗試從 Header 中取得 Authentication 的值,其格式如下:

Authentication: Bearer <jwt-token>

取得 JWT Token 後,再用 JwtTokenProvider.validate() 來驗證。通過的話,再用 JwtTokenProvider.toAuthentication() 轉成 Authentication 物件,並設定到 SecurityContextHolder。這樣,這個 HTTP Request 就是有驗證過的。

覆寫 ExceptionTranslationFilter 的錯誤處理

當前端還沒有登入就要存取後端資源時,就會到 startAuthentication() 流程。這流程最後就是呼叫 AuthenticationEntryPoint.commence()。顧名思義,AuthenticationEntryPoint 是提供前端驗證的入口。Spring Security 預設的 AuthenticationEntryPoint 是重新導向到登入頁面。而我們這邊只想要告知前端,此 HTTP Request 是沒有驗證過的,這裡就沒有 EntryPoint 的意思了。所以新增 JwtAuthenticationEntryPoint,程式碼如下:

package com.waynestalk.jwtexample.auth

import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.AuthenticationEntryPoint
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class JwtAuthenticationEntryPoint : AuthenticationEntryPoint {
    override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message)
    }
}

當前端已經登入,但是卻存取沒有權限的資源。如,Jack 只有 USER 權限,但是想要存取要有 ADMIN 權限的資源時,就會到 AccessDeniedHandler 裡去。和 JwtAuthenticationEntryPoint 一樣,告知前端此 HTTP Request 是沒有驗證過的。新增 JwtAccessDeniedHandler,程式碼如下:

package com.waynestalk.jwtexample.auth

import org.springframework.security.access.AccessDeniedException
import org.springframework.security.web.access.AccessDeniedHandler
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse

class JwtAccessDeniedHandler : AccessDeniedHandler {
    override fun handle(request: HttpServletRequest, response: HttpServletResponse, accessDeniedException: AccessDeniedException) {
        response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.message)
    }
}

建立 LoginController

當進來的 HTTP Request 是 POST /login 時,就會進到 LoginController.login() 做登入的流程。其程式碼如下:

package com.waynestalk.jwtexample.controller

import com.waynestalk.jwtexample.auth.JwtFilter
import com.waynestalk.jwtexample.auth.JwtTokenProvider
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletResponse
import javax.validation.Valid
import javax.validation.constraints.NotBlank

@RestController
class LoginController(val jwtTokenProvider: JwtTokenProvider,
                      val authenticationManagerBuilder: AuthenticationManagerBuilder) {
    @PostMapping("/login")
    fun login(@Valid @RequestBody request: LoginRequest, httpServletResponse: HttpServletResponse): LoginResponse {
        val authenticationToken = UsernamePasswordAuthenticationToken(request.username, request.password)
        val authentication = authenticationManagerBuilder.`object`.authenticate(authenticationToken)
        SecurityContextHolder.getContext().authentication = authentication

        val token = jwtTokenProvider.generate(authentication)
        httpServletResponse.addHeader(JwtFilter.authenticationHeader, "${JwtFilter.authenticationScheme} $token")

        return LoginResponse(token)
    }

    data class LoginRequest(@field:NotBlank val username: String,
                            @field:NotBlank val password: String)

    data class LoginResponse(val token: String)
}

可以看到,就是 authenticationManagerBuilder.object.authenticate(authenticationToken) 會做驗證。這裡面的整個驗證流程,底下的文章中,有詳細的解說。

它裡面最終會呼叫 UserDetailsService 來取得 Username 的 Password。而且,它是一個 Bean。所以,我們新增 MemberUserDetailsService,在裡面從資料庫取出 Password 和 Authorities。其程式碼如下:

package com.waynestalk.jwtexample.auth

import com.waynestalk.jwtexample.repository.MemberRepository
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Component

@Component
class MemberUserDetailsService(private val memberRepository: MemberRepository) : UserDetailsService {
    override fun loadUserByUsername(username: String): UserDetails {
        val member = memberRepository.findByUsername(username)
                ?: throw UsernameNotFoundException("$username was not found")

        val authority = member.authorities.map { SimpleGrantedAuthority(it) }
        return User(member.username, member.password, authority)
    }
}

然後,利用 JwtTokenProvider.generate() 產生一個 JWT Token,並回傳給前端。整個登入流程就完成了。

設定 Spring Security

再來就是要設定 Spring Security 了。新增 SecurityConfiguration,其程式碼如下:

package com.waynestalk.jwtexample.config

import com.waynestalk.jwtexample.auth.JwtAccessDeniedHandler
import com.waynestalk.jwtexample.auth.JwtAuthenticationEntryPoint
import com.waynestalk.jwtexample.auth.JwtFilter
import com.waynestalk.jwtexample.auth.JwtTokenProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
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
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration(val jwtTokenProvider: JwtTokenProvider) : WebSecurityConfigurerAdapter() {
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

    override fun configure(http: HttpSecurity) {
        http
                .csrf().disable()

                .addFilterBefore(JwtFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java)

                .exceptionHandling()
                .authenticationEntryPoint(JwtAuthenticationEntryPoint())
                .accessDeniedHandler(JwtAccessDeniedHandler())

                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
    }
}

我們建立一個 BCryptPasswordEncoder 來取代預設的 DelegatingPasswordEncoder。

在 configure() 裡,我們將 JwtFilter 插入到 UsernamePasswordAuthenticationFilter 的前面。然後在 exceptionHandling() 中,用 JwtAuthenticationEntryPoint 和 JwtAccessDeniedHandler 來取代預設的。設定 SessionCreationPolicy 為 Stateless。最後,允許 /login 不需要驗證就可以存取,這樣才前端才有辦法做登入。

整個專案,大致這樣就完成了。但,我們還要新增 MemberController。它提供 GET /greet 資源,而且要 ADMIN 權限才可以存取,等等我們要用這個 API 來測試權限的部分。

package com.waynestalk.jwtexample.controller

import com.waynestalk.jwtexample.repository.MemberRepository
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest

@RestController
class MemberController(private val memberRepository: MemberRepository) {
    @GetMapping("/greet")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    fun greet(request: HttpServletRequest): GreetResponse {
        val member = memberRepository.findByUsername(request.userPrincipal.name)!!
        return GreetResponse("Hello ${member.name}")
    }

    data class GreetResponse(val message: String)
}

測試

現在來測試我們剛剛建立好的專案。我們利用下面的指令來登入 Monika。

% curl --location --request POST 'http://localhost:8080/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "monika",
    "password": "123456"
}'

登入成功後,會拿到含有 Token 的 Response,如下:

{
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtb25pa2EiLCJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sIFJPTEVfVVNFUiIsImV4cCI6MTU5Nzk5MzM0MX0.eUZFFKO_q31kT4soZCCphwxVIQEN-Ha2nZoo_kpb2ckpr9I8exMN8YDNxn-NflOK4xqCEi_eSwx9Eu8Q7XKg4Q"
}

再用這個 Token 來存取 GET /greet。

% curl --location --request GET 'http://localhost:8080/greet' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtb25pa2EiLCJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sIFJPTEVfVVNFUiIsImV4cCI6MTU5Nzk5MzM0MX0.eUZFFKO_q31kT4soZCCphwxVIQEN-Ha2nZoo_kpb2ckpr9I8exMN8YDNxn-NflOK4xqCEi_eSwx9Eu8Q7XKg4Q'

會收到下面的 JSON。

{
    "message": "Hello Monika"
}

若是用 Jack 的身份去存取 GET /greet 的話,會收到 403 錯誤。

{
    "timestamp": "2020-08-21T06:09:34.225+00:00",
    "status": 403,
    "error": "Forbidden",
    "message": "",
    "path": "/greet"
}

JWT-Token-Based Authentication + Springdoc

Springdoc 套件可以幫我們產生 Swagger 介面的 API 文件。如果不了解 Springdoc,可以先閱讀下面這篇文章。

引入 Springdoc 後,新增 SaggerConfiguration 來設定 Springdoc,其程式碼如下:

package com.waynestalk.jwtexample.config

import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class SwaggerConfiguration {
    @Bean
    fun customOpenAPI(): OpenAPI {
        val securitySchemeName = "Auth JWT"
        return OpenAPI()
                .addSecurityItem(SecurityRequirement().addList(securitySchemeName))
                .components(
                        Components()
                                .addSecuritySchemes(securitySchemeName,
                                        SecurityScheme()
                                                .name(securitySchemeName)
                                                .type(SecurityScheme.Type.HTTP)
                                                .scheme("bearer")
                                                .bearerFormat("JWT")
                                )
                )
                .info(Info().title("Wayne's Talk APIs").version("v1.0.0"))
    }
}

這樣還不夠,還要設定 Spring Security,讓它允許 Springdoc 相關的 URI 可以不需要驗證就通過。

class SecurityConfiguration(val jwtTokenProvider: JwtTokenProvider) : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
                .authorizeRequests()
                .antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated()
    }
}

現在可以瀏覽 http://localhost:8080/swagger-ui.html 了。然後試著在 Swagger 上做登入,登入後拷貝收到的 Token。然後,點擊右上方的 Authorize 按鈕,會出現下面的畫面。貼上剛剛收到的 Token,記得不需要 Bearer 前綴,再按下 Authorize。接下來,在 Swagger 上去存取 GET /greet,可以看到 Swagger 會幫你帶入 Token。

Swagger Authorization
Swagger Authorization

結語

本章中的登入與驗證流程,可以看出客製化了很多。或許就會覺得,既然幾乎都是要自己寫,那何必還硬要整進 Spring Security 呢?這樣說也並不無道理。不整合 Spring Security 的話,也許程式碼還可以更簡單一些。不過,這樣的話就無法使用 Spring Security 的 Authorization 與 Method Security 了。如果要自己做 Authorization 的話,那程式碼可就會多很多了,讀者可以評估一下自己的需求。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Yustinus Subiakto on Unsplash
Read More

Spring Boot + RESTful + 圖片下載

用 Spring Boot 常常要實作一些 RESTful API,來讓前端程式下載圖片。我們將介紹兩種不同的實作方式。一種是用瀏覽器開啟圖片網址時,會直接將圖片下載成檔案。另外一種是,瀏覽器會直接顯示圖片。
Read More