Search code examples
htmlkotlinfreemarkerktor

How to set authentication in Kotlin Ktor Routing for every route and pass a the session name globally to Freemarker?


I'm building a web app with Ktor in Kotlin and just added the authentication with form-auth and session-auth. Now the check if the user is logged in only happens on the /home route. I have several routes like /books, /changelog and so on. So how can I configure my Routing.kt to check before every route if the is user is logged in and if not to show the login form?

After the check I want to display the session name in a Freemarker template in my UI. At the moment the name is only shown on the /home route. On the other routes I only get the placeholder "Hello Guest". I'm passing the variable to my Freemarker _layout file.

How can I pass the name of the session globally to Freemarker to see it on every route?

Here is my code so far:

Routing.kt:

fun Application.configureRouting() {
routing {
    static("/static") {
        resources("files")
    }

    get("/") {
        call.respondRedirect("home")
    }

    route("login") {
        get {
            call.respondHtml {
                head {
                    style {
                        unsafe {
                            +"""
          .container {
            height: 100vh;
            width: 100vw;
            display: flex;
            align-items: center;
            justify-content: center;
          }
          form {
            text-align: center;
          }
        """
                        }
                    }
                }
                body {
                    div("container") {
                        form(
                            action = "/login",
                            encType = FormEncType.applicationXWwwFormUrlEncoded,
                            method = FormMethod.post
                        ) {
                            p {
                                +"Username:"
                                textInput(name = "username")
                            }
                            p {
                                +"Password:"
                                passwordInput(name = "password")
                            }
                            p {
                                submitInput() { value = "Login" }
                            }
                        }
                    }
                }
            }
        }
    }

    authenticate("auth-form") {
        post("/login") {
            val userName = call.principal<UserIdPrincipal>()?.name.toString()
            call.sessions.set(UserSession(name = userName))
            call.respondRedirect("/home")
        }
    }

    authenticate("auth-session") {
        get("/home") {
            val userSession = call.principal<UserSession>()
            val name = userSession?.name
            call.respond(FreeMarkerContent("home.ftl", mapOf("user" to mapOf("name" to name))))
        }
    }

    get("/logout") {
        call.sessions.clear<UserSession>()
        call.respondRedirect("/login")
    }

    route("changelog") {
        get {
            call.respond(FreeMarkerContent("changelog.ftl", model = null))
        }
    }
    route("books") {
        get {
            call.respond(FreeMarkerContent("books.ftl", model = null))
        }
    }
  }
}

Application.kt:

fun Application.module() {
install(Sessions) {
    cookie<UserSession>("user_session") {
        cookie.path = "/"
        cookie.maxAgeInSeconds = 3600
    }
 }

install(Authentication) {
    form("auth-form") {
        userParamName = "username"
        passwordParamName = "password"
        validate { credentials ->
            if (credentials.name == Constants.userName && credentials.password == Constants.password) {
                UserIdPrincipal(credentials.name)
            } else {
                null
            }
        }
        challenge {
            call.respond(HttpStatusCode.Unauthorized, "Credentials are not valid")
        }
    }

    session<UserSession>("auth-session") {
        validate { session ->
            if (session.name.startsWith("jet")) {
                session
            } else {
                null
            }
        }
        challenge {
            call.respondRedirect("/login")
        }
    }
}

DatabaseFactory.init()
// configureSerialization()
configureTemplating()
configureRouting()
}

_layout.ftl:

<#macro header>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
    <link rel="stylesheet" href="/static/style.css" />
    <link href="/static/favicon.ico" rel="shortcut icon" type="image/x-icon" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
    <title>Ktor App.</title>

</head>
<body style="text-align: center; font-family: sans-serif" onload="loadImage()">
<header>
    <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-dark bg-primary border-bottom shadow">
        <div class="container">
            <a class="navbar-brand"> <img src="/static/favicon-16x16.png" /> Books</a>
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="#navbarNav"
                    aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="navbar-collapse collapse" id="navbarNav">
                <ul class="navbar-nav" style="display: flex; align-items: center;">
                    <li class="nav-item">
                        <a class="nav-link" href="/">Home</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/books">Books</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/changelog">Changelog</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/search">Search</a>
                    </li>
                    <#if user?? && user.name?has_content>
                    <li class="nav-item">
                        <a class="nav-link"> <i class="fa fa-user" aria-hidden="true" style="color:white;"></i> ${user.name}</a>
                        <#else>
                        <a class="nav-link"> <i class="fa fa-user" aria-hidden="true" style="color:white;"></i> Hello Guest</a>
                        </#if>
                    </li>
                    <li class="nav-item">
                        <a class="btn btn-danger" href="/logout">
                            <i class="fa fa-sign-out" aria-hidden="true"></i>
                            Logout
                        </a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>
</header>
<#--<img src="/static/ktor_logo.png">
<h1>Kotlin Ktor Journal </h1>
<p><i>Powered by Ktor & Freemarker!</i></p>
<hr>-->
<#nested>
<!-- <a href="/">Back to the main page</a> -->
<footer class="border-top footer bg-teal text-light text-center fixed-bottom">
    <div class="container">
        &copy; 2022
    </div>
</footer>
<script src="/static/switch.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"  crossorigin="anonymous"></script>
</body>
</html>
</#macro>

Solution

  • You can write a helper function to merge a specific model with a session data:

    suspend fun ApplicationCall.respondFreemarker(template: String, model: Map<Any, Any>) {
        val session = sessions.get<UserSession>()
        respond(FreeMarkerContent(template, mapOf("session" to session) + model))
    }
    

    Here is a full example:

    fun main() {
        embeddedServer(Netty, host = "0.0.0.0", port = 12345) {
            install(Sessions) {
                cookie<UserSession>("cookie_name")
            }
            install(FreeMarker) {
                templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates")
            }
            install(Authentication) {
                session<UserSession>("auth-session") {
                    validate { session ->
                        session
                    }
                    challenge {
                        call.respond(HttpStatusCode.Unauthorized)
                    }
                }
            }
    
            routing {
                authenticate("auth-session") {
                    get("/a") {
                        call.respondFreemarker("index.ftl", mapOf())
                    }
    
                    get("/b") {
                        call.respondFreemarker("index.ftl", mapOf())
                    }
                }
    
                get("/login") {
                    val name = call.request.queryParameters["name"] ?: return@get
                    call.sessions.set(UserSession(name))
                    call.respond(HttpStatusCode.OK)
                }
            }
        }.start(wait = true)
    }
    
    suspend fun ApplicationCall.respondFreemarker(template: String, model: Map<Any, Any>) {
        val session = sessions.get<UserSession>()
        respond(FreeMarkerContent(template, mapOf("session" to session) + model))
    }
    
    data class UserSession(val name: String): Principal
    

    index.ftl:

    <html>
    <body>
        <h1>${session.name}</h1>
    </body>
    </html>