Spring Security + Google Sign-In + JWT + JPA 實例解析

Photo by Kamile Leo on Unsplash
Photo by Kamile Leo on Unsplash
本章將介紹在 Spring Security 下,如何在前端做 Google Sign-In 登入後,取得 Authorization Code 並傳給後端。後端和 Google 驗證過後,再傳 JWT 給前端。

本章將介紹在 Spring Security 下,如何在前端做 Google Sign-In 登入後,取得 Authorization Code 並傳給後端。後端和 Google 驗證過後,再傳 JWT token 給前端。過程中,將從 Google Sign-In 取得的 Email 和從 JPA 讀取的帳號做比對。

完整程式碼可以在 下載。

OAuth Login 的問題

Spring Security 的 OAuth Login 可以讓我們透過 Google Sign-In 做第三方登入。OAuth Login 的流程是,由後端對 OAuth Server 發出 Authorization Code 的請求。然後,OAuth Server 會將前端重新導向到登入畫面。前端登入後,OAuth Server 在傳 Authorization Code 給後端。最後,後端再對 OAuth Server 發出驗證 Authorization Code、Client ID、和 Client Secret 的請求。OAuth Server 驗證成功後,傳回 ID Token 和 Access Token 給後端。如果,不熟悉以上的流程的話,可以先閱讀以下這篇文章。

由上述的流程可知,OAuth Login 是完全符合網頁的運作模式,但是不完全適合手機前端。至少,重新導向這點在手機端就不是那麼地直覺。

OAuth2 + JWT-Token-Based Authentication

本章要介紹的方式是,結合 OAuth Login 和 JWT-Token-Based 的模式。大致流程會像是 Google Sign-In for server-side apps 所描述的一樣,如下圖。

Server-Side Flow from Google Sign-In for Server-Size Apps
Server-Side Flow from Google Sign-In for Server-Size Apps

前端直接向 Google API Server 發出 Authorization 請求,然後重新導向到 OAuth 2.0 登入畫面。使用者登入後,回傳 id_token 和 access_token 給前端。前端再將 id_token 和 access_token 發送給後端。後端會利用 id_token 和 access_token 向 Google API Server 做驗證。驗證成功的話,就告知前端登入成功,整個流程大致完成。

與上面的不同是的,我們在前端會跟 Google API Server 要 Authorization Code,再將它傳給後端。後端就會將 Authorization Code、Client ID、和 Client Secret 送到 Google API Server 做驗證。成功後,將收到的 OAuth 的資訊做成 JWT 傳給前端。之後,前端就用 JWT 做驗證,同下面文章所介紹的 JWT-Token-Based Authentication。

為何我們不用 id_token 和 access_token,而改用 Authorization Code 呢?這是因為 Spring Security 的 OAuth Login 是採用 Authorization Code 流程。所以和它用一樣的方式的話,我們可以借用 OAuth Login 的驗證流程,這樣一來我們可以少很多程式碼。如果用 id_token 和 access_token,那就連同驗證流程都要重寫了。

OAuth2 + JWT-Token-Based Authentication 流程

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

OAuth2 + JWT Flow
OAuth2 + JWT Flow

如果對圖中的 SecurityFilterChain、ExceptionTranslationFilter、FilterSecurityInterceptor 不熟悉的話,請先閱讀下面的文章。

構想是在 SecurityFilterChain 中加入兩個 Filter,分別是 RestOAuth2AuthorizationFilter 和 RestOAuth2AuthenticationFilter。登入時,進入 RestOAuth2AuthenticationFilter 的流程,最後回傳前端一個 JWT。之後,前端的請求會帶著這個 JWT,RestOAuth2AuthorizationFilter 就會負責驗證這個 JWT。

OAuth2 + JWT-Token-Based Authentication 架構

下圖是顯示在 OAuth Login 的架構下,我們如何去修改成我們的架構。紅色的部分就是我們會新增的,其餘都還是借用 OAuth Login 的。如果我們用 id_token 和 access_token 的話,那就會連同橘色的部分都要實作了。

OAuth2 + JWT Architecture
OAuth2 + JWT Architecture

OAuth2 + JWT-Token-Based Authentication 專案

在這專案中,透過 Google Sign-In 驗證後,再將取得的使用者 Email 作為帳號。再從 JPA 讀取使用者帳號並做比對,如果存在則進行比對,如果不存在則會創建新帳號。

建立 Google OAuth Credentials

首先,我們要先取得一組 Client ID 和 Client Secret。在 Google Console 上,創建 Credentials 就可以了,下面的文章有說明詳細的步驟。

不過在 Authorized redirect URIs 的地方,不需要填入任何 URI,如下圖。

Create OAuth Client ID
Create OAuth Client ID

建立專案

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

OAuth2 + JWT Project
OAuth2 + JWT Project

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

因為我們還會用到 Jackson 來解析 JSON 字串,以及用 JJWT 來產生 JWT,所以還要再引入以下的 Dependencies。

dependencies {
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

    implementation("io.jsonwebtoken:jjwt-api:0.11.2")
    implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
    implementation("io.jsonwebtoken:jjwt-jackson:0.11.2")
}

設定 Client DI & Client Secret

把剛剛產生的 Client ID 和 Client Secret 寫到 application.properties。

spring.security.oauth2.client.registration.google.client-id=63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com
spring.security.oauth2.client.registration.google.client-secret=JP-lvQxE8eo359zgQMKpvC5k

login.html

新增 login.html,其程式碼如下:

<html>
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="https://apis.google.com/js/client:platform.js?onload=renderButton" async defer></script>
    <script>
        function renderButton() {
            gapi.load('auth2', function () {
                auth2 = gapi.auth2.init({
                    client_id: '63245096424-6nn16rjn23h8p11g8bkp2aogl4kqneuc.apps.googleusercontent.com',
                    scope: 'profile email',
                });
            });
        }
    </script>
</head>
<body>
<button id="signInButton" onclick="onClickSignIn()">Sign in with Google</button>
<button onclick="onClickAdmin()">Admin Endpoint</button>
<button onclick="onClickUser()">User Endpoint</button>
<script>
    let token;

    function onSuccess(result) {
        console.log(result);

        authenticate(result.code)
            .then(res => {
                token = res.data.token;
                console.log(res);
            })
            .catch(console.log);
    }

    function authenticate(code) {
        return axios.post('http://localhost:8080/auth/google', JSON.stringify({code}));
    }

    function callEndpoint(uri, token) {
        return axios.get(`http://localhost:8080/${uri}`, {headers: {Authorization: `Bearer ${token}`}});
    }

    function onFailure(error) {
        console.log(error);
    }

    function onClickSignIn() {
        auth2.grantOfflineAccess()
            .then(onSuccess)
            .catch(onFailure);
    }

    function onClickAdmin() {
        callEndpoint('admin', token)
            .then(console.log)
            .catch(console.log)
    }

    function onClickUser() {
        callEndpoint('user', token)
            .then(console.log)
            .catch(console.log)
    }
</script>
</body>
</html>

login.html 會顯示三個按鈕,分別是 Sign In with Google、Admin Endpoint、和 User Endpoint。使用者會先點擊 Sign In with Google 做第三方登入,登入成功後,Google 會回傳 Authorization Code,再將它傳給後端,後端再利用它來做登入。登入後,使用者可以點擊 Admin Endpoint 或 User Endpoint 來取得使用者資料。不同的是,Admin Endpoint 是需要 Admin 權限。

RestOAuth2AuthenticationFilter

使用者取得 Authorization Code 後,會對後端請求 POST /auth/google 並帶入 Authorization Code 來做登入。RestOAuth2authenticationFilter 會來處理這個請求,其中的流程和 OAuth Login 中的 OAuth2LoginAuthenticationFilter 很像,建議先閱讀下面的文章。

新增 RestOAuth2authenticationFilter,其程式碼如下:

@Component
class RestOAuth2AuthenticationFilter(
        private val clientRegistrationRepository: ClientRegistrationRepository,
        private val authorizedClientRepository: OAuth2AuthorizedClientRepository,
        private val tokenManager: TokenManager,
        private val userDetailsService: MemberUserDetailsService,
        private val memberRepository: MemberRepository
) : GenericFilterBean() {
    companion object {
        private const val contentTypeHeader = "Content-Type"
        private const val baseUri = "/auth"
        private const val registrationIdUriVariableName = "registrationId"
        private const val redirectUri = "postmessage"
        private const val nonceParameterName = "nonce"
    }

    private val authenticationManager: AuthenticationManager
    private val authorizationRequestResolver = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, baseUri)
    private val requestMatcher = AntPathRequestMatcher("$baseUri/{$registrationIdUriVariableName}", HttpMethod.POST.name)

    init {
        val accessTokenResponseClient = DefaultAuthorizationCodeTokenResponseClient()
        val userService = OidcUserService()
        val authenticationProvider = OidcAuthorizationCodeAuthenticationProvider(accessTokenResponseClient, userService)
        authenticationManager = ProviderManager(authenticationProvider)

        authorizationRequestResolver.setAuthorizationRequestCustomizer {
            it.redirectUri(redirectUri)
            it.additionalParameters { additionalParameters -> additionalParameters.remove(nonceParameterName) }
            it.attributes { attributes -> attributes.remove(nonceParameterName) }
        }
    }

    override fun doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain) {
        val request = req as HttpServletRequest
        val response = res as HttpServletResponse

        if (!requireAuthentication(request)) {
            chain.doFilter(request, response)
            return
        }

        try {
            val authentication = authenticate(request, response)
            successfulAuthentication(response, authentication)
        } catch (e: Exception) {
            unsuccessfulAuthentication(response, e)
        }
    }

    private fun requireAuthentication(request: HttpServletRequest) = requestMatcher.matches(request)

    private fun authenticate(request: HttpServletRequest, response: HttpServletResponse): OAuth2AuthenticationToken {
        val code = readCode(request)
                ?: throw OAuth2AuthenticationException(OAuth2Error("authentication_code_missing"))
        val registrationId = requestMatcher.matcher(request).variables[registrationIdUriVariableName]
                ?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found"))
        val clientRegistration = clientRegistrationRepository.findByRegistrationId(registrationId)
                ?: throw OAuth2AuthenticationException(OAuth2Error("client_registration_not_found"))

        val authorizationRequest = authorizationRequestResolver.resolve(request, registrationId)

        val authorizationResponse = OAuth2AuthorizationResponse
                .success(code)
                .redirectUri(redirectUri)
                .state(authorizationRequest.state)
                .build()

        val authenticationRequest = OAuth2LoginAuthenticationToken(
                clientRegistration, OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse))

        val authenticationResult = authenticationManager.authenticate(authenticationRequest)
                as OAuth2LoginAuthenticationToken

        val username = authenticationResult.principal.attributes["email"] as String
        val user = loadUser(username) ?: createUser(authenticationResult)
        val authorities = mergeAuthorities(authenticationResult, user)

        val oauth2Authentication = OAuth2AuthenticationToken(
                authenticationResult.principal,
                authorities,
                authenticationResult.clientRegistration.registrationId)

        val authorizedClient = OAuth2AuthorizedClient(
                authenticationResult.clientRegistration,
                oauth2Authentication.name,
                authenticationResult.accessToken,
                authenticationResult.refreshToken)

        authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response)

        return oauth2Authentication
    }

    private fun readCode(request: HttpServletRequest): String? {
        val authRequest: Map<String, Any> = jacksonObjectMapper().readValue(request.reader)
        return authRequest["code"] as? String
    }

    private fun loadUser(username: String): UserDetails? {
        return try {
            userDetailsService.loadUserByUsername(username)
        } catch (e: Exception) {
            null
        }
    }

    private fun createUser(authentication: OAuth2LoginAuthenticationToken): UserDetails {
        val attributes = authentication.principal.attributes
        val username = attributes["email"] as String
        val member = Member(
                username,
                authentication.clientRegistration.registrationId,
                attributes["name"] as String,
                listOf("ROLE_USER")
        )
        memberRepository.save(member)

        return userDetailsService.loadUserByUsername(username)
    }

    private fun mergeAuthorities(authentication: OAuth2LoginAuthenticationToken, user: UserDetails): Collection<GrantedAuthority> {
        val authorities = HashSet<GrantedAuthority>()
        authorities.addAll(authentication.authorities)
        authorities.addAll(user.authorities)
        return authorities
    }

    private fun successfulAuthentication(response: HttpServletResponse, authentication: OAuth2AuthenticationToken) {
        SecurityContextHolder.getContext().authentication = authentication

        val token = tokenManager.generate(authentication)
        tokenManager[token] = authentication

        response.addHeader(contentTypeHeader, MediaType.APPLICATION_JSON_VALUE)
        response.writer.println(jacksonObjectMapper().writeValueAsString(TokenResponse(token)))
    }

    private fun unsuccessfulAuthentication(response: HttpServletResponse, exception: Exception) {
        SecurityContextHolder.clearContext()
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, exception.message)
    }

    private data class TokenResponse(val token: String)
}

doFilter() 先檢查請求的 URI 是不是 /auth/{registrationId},不是的話就會直接跳到下一個 Filter。在 login.html 裡,它對 POST /auth/google 發出登入的請求,所以在這裡 registrationId 就會是 google。這邊的邏輯是沿用 OAuth Login 的。

接下來,取得 Authorization Code、registrationId、和 ClientRegistration。然後,產生出 OAuth2LoginAuthenticationToken,並呼叫 authenticationManager.authenticate() 做驗證。

authenticationManager 中,我們加入了 OidcAuthorizationCodeAuthenticationProvider,因為 Google Sign-In 是使用 Oidc 協定。如果是用 OAuth 2.0 的話,如 GitHub,可以再加入 OAuth2AuthorizationCodeAuthenticationProvider。

驗證通過後,我們取得使用者的 Email。與 JPA 中的帳號做比對,如果不存在,我們就創建一個帳號。

最後,產生一個 OAuth2AuthenticationToken,其包含了使用者的基本資料和權限。successfulAuthentication() 會將這 Authentication 物件放入 SecurityContextHolder。然後,產生一個 JWT,將它和 Authentication 物件放入 Cache 裡。最後,回傳 JWT 給前端。

RestOAuth2AuthorizationFilter

前端取得 JWT 後,在請求任何的 REST APIs 時,在 Header 都要帶入 JWT,如下:

Authorization: Bearer <JWT>

RestOAuth2AuthorizationFilter 則會攔截每一個請求,並檢查是否有帶 JWT,其程式碼如下:

@Component
class RestOAuth2AuthorizationFilter(private val tokenManager: TokenManager) : GenericFilterBean() {
    companion object {
        private const val authenticationHeader = "Authorization"
        private const val authenticationScheme = "Bearer"
    }

    override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) {
        val token = extractToken(request as HttpServletRequest)
        if (token != null && StringUtils.hasText(token)) {
            val authentication = tokenManager[token]
            if (authentication != null) {
                SecurityContextHolder.getContext().authentication = authentication
            }
        }

        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 null
    }
}

如果檢查到有 JWT 的話,它就會從 Cache 中取出 Authentication 物件,並放入 SecurityContextHolder,就這樣就等於認證成功!

建立資料庫

新增 Member Entity,包含 usernameregistrationId、和 authorities

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

新增 MemberRepository 來讀取資料庫。

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 權限。

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

新增 MemberUserDetailsService,因為 RestOAuth2AuthenticationFilter 會利用它來讀取使用者資料。

@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, "", authority)
    }
}

TokenManager

新增 TokenManager 來管理 JWT 和 Authentication。而且,還提供產生和驗證 JWT 的 Method。

@Component
class TokenManager(cacheManager: CacheManager) {
    companion object {
        private const val claimAuthorities = "authorities"
        private const val claimName = "name"
        private const val claimEmail = "email"

        private const val secret = "qsbWaaBHBN/I7FYOrev4yQFJm60sgZkWIEDlGtsRl7El/k+DbUmg8nmWiVvEfhZ91Y67Sc6Ifobi05b/XDwBy4kXUcKTitNqocy7rQ9Z3kMipYjbL3WZUJU2luigIRxhTVNw8FXdT5q56VfY0LcQv3mEp6iFm1JG43WyvGFV3hCkhLPBJV0TWnEi69CfqbUMAIjmymhGjcbqEK8Wt10bbfxkM5uar3tpyqzp3Q=="
        private val key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
    }

    private val cache = cacheManager.getCache("tokenManager")!!

    operator fun get(token: String): OAuth2AuthenticationToken? {
        return if (validate(token)) {
            val authentication = cache.get(token)?.get()
            authentication as OAuth2AuthenticationToken
        } else {
            cache.evict(token)
            null
        }
    }

    operator fun set(token: String, authentication: OAuth2AuthenticationToken) {
        cache.put(token, authentication)
    }

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

    private fun validate(token: String): Boolean {
        return try {
            val jwtParser = Jwts.parserBuilder().setSigningKey(key).build()
            jwtParser.parse(token)
            true
        } catch (e: Exception) {
            false
        }
    }
}

另外,用下面的程式碼來啟動 Cache。

@Configuration
@EnableCaching
class CacheConfig {
}

MemberController

新增 MemberController,其程式碼如下:

@RestController
class MemberController(private val authorizedClientRepository: OAuth2AuthorizedClientRepository) {
    @GetMapping("/user")
    @PreAuthorize("hasRole('ROLE_USER')")
    fun user(principal: Principal): UserResponse {
        val authentication = principal as OAuth2AuthenticationToken
        return UserResponse(
                authentication.principal.attributes["email"] as String,
                authentication.principal.attributes["name"] as String
        )
    }

    data class UserResponse(val email: String, val name: String)

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ROLE_ADMIN')")
    fun admin(principal: Principal, request: HttpServletRequest): AdminResponse {
        val authentication = principal as OAuth2AuthenticationToken
        val authorizedClient = authorizedClientRepository.loadAuthorizedClient<OAuth2AuthorizedClient>(
                authentication.authorizedClientRegistrationId,
                authentication,
                request)
        return AdminResponse(
                authentication.principal.attributes["email"] as String,
                authentication.principal.attributes["name"] as String,
                authorizedClient.accessToken.tokenValue,
                authorizedClient.refreshToken?.tokenValue ?: ""
        )
    }

    data class AdminResponse(val email: String, val name: String, val accessToken: String, val refreshToken: String)
}

MemberController 提供了 2 個 Endpoint,其中 admin() 需要 ROLE_ADMIN 的權限,也就是只有 Monika 才能成功發起。

設定 Spring Security

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

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfig(
        private val restfulOAuth2AuthorizationFilter: RestOAuth2AuthorizationFilter,
        private val restfulOAuth2AuthenticationFilter: RestOAuth2AuthenticationFilter
) : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
                .csrf().disable()

                .addFilterBefore(restfulOAuth2AuthorizationFilter, BasicAuthenticationFilter::class.java)
                .addFilterBefore(restfulOAuth2AuthenticationFilter, BasicAuthenticationFilter::class.java)

                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .exceptionHandling()
                .authenticationEntryPoint(RestOAuth2AuthenticationEntryPoint())
                .accessDeniedHandler(RestOAuth2AccessDeniedHandler())

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

我們將 RestOAuth2AuthorizationFilter 和 RestOAuth2AuthenticationFilter 插入到 SecurityFilterChain 中。並且,設定 AuthenticationEntryPoint 和AccessDeniedHandler。

測試

執行專案後,瀏覽 http://localhost:8080/login.html。先點擊 Sign In with Google 做第三方登入,成功會,會自動向後端請求登入。登入成功後,再點擊 User Endpoint 來測試是否可以成功存取。最後,再點擊 Admin Endpoint,如果沒有 ROLE_ADMIN 權限的話,應該要被禁止存取。

Springdoc

如果想到整合 Springdoc 的話,其模式會像以下的文章所用的方式一樣。將取得的 JWT 設定到 Springdoc。之後發起的任何請求,Springdoc 都會自動幫你帶入 JWT。

結語

Spring Security 的 OAuth Login 對 OAuth 2.0 已經提供很好的實作,但是對手機程式卻不是那麼地友善。因為它使用 Cookies 而且不是 RESTful APIs 的架構。當然,我們也可以完全不借用 OAuth Login 的程式碼,自己實作和 Google Sign-In 驗證的部分。但這樣的話,對於每一種 OAuth 2.0 Server 我們都要實作,這也是蠻麻煩的。

2 comments
  1. Hello Wayne,

    Great article as always. I have few queries. If I would like to implement this authentication at api gateway (mostly zuul) generate the jwt token. All of downstream services are microservices so can I implement the authorisation part in each of microservices (I cannot not leverage the localhost network provided security because these apps can be deployed in multiple cloud providers/some may be in on-prem).

    I would like to make all of my downstream services as resource servers, each can validate the jwt token generated using same key. Any thoughts on this?

    Thank you

    1. Hi Sai,
      I think so. Each micro-service can run on different machine, so each of them has to validate the JWT token with the same key.

發佈留言

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

You May Also Like
Photo by Charles Jackson on Unsplash
Read More

Springdoc-OpenAPI 教學

Springdoc 是一個整合 OpenAPI Specification 和 Spring Boot 的套件。和 SpringFox 套件一樣,它產出 Swagger 文件。兩者不同在於,Springdoc 是用 Swagger 3,而 SpringFox 是用 Swagger 2。
Read More
Photo by Yustinus Subiakto on Unsplash
Read More

Spring Boot + RESTful + 圖片下載

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