com.lightningkite.lightningserver.auth.EmailAuthEndpoints.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of server-core Show documentation
Show all versions of server-core Show documentation
A set of tools to fill in/replace what Ktor is lacking in.
The newest version!
package com.lightningkite.lightningserver.auth
import com.lightningkite.lightningserver.HtmlDefaults
import com.lightningkite.lightningserver.cache.Cache
import com.lightningkite.lightningserver.core.ContentType
import com.lightningkite.lightningserver.core.ServerPathGroup
import com.lightningkite.lightningserver.email.Email
import com.lightningkite.lightningserver.email.EmailClient
import com.lightningkite.lightningserver.email.EmailLabeledValue
import com.lightningkite.lightningserver.http.*
import com.lightningkite.lightningserver.settings.generalSettings
import com.lightningkite.lightningserver.tasks.Tasks
import com.lightningkite.lightningserver.typed.ApiEndpoint0
import com.lightningkite.lightningserver.typed.ApiExample
import com.lightningkite.lightningserver.typed.typed
import java.net.URLDecoder
import java.time.Duration
import java.util.*
/**
* Endpoints for authenticating via email magic link / sent PINs.
* Also allows for OAuth based login, as most OAuth systems share email as a common identifier.
* For information on setting up OAuth, see the respective classes, [OauthAppleEndpoints], [OauthGitHubEndpoints], [OauthGoogleEndpoints], [OauthMicrosoftEndpoints].
*/
open class EmailAuthEndpoints(
val base: BaseAuthEndpoints,
val emailAccess: UserEmailAccess,
private val cache: () -> Cache,
private val email: () -> EmailClient,
val pinAvailableCharacters: List = ('0'..'9').toList(),
val pinLength: Int = 6,
val pinExpiration: Duration = Duration.ofMinutes(15),
val pinMaxAttempts: Int = 5,
private val emailSubject: () -> String = { "${generalSettings().projectName} Log In" },
private val template: (suspend (email: String, link: String, pin: String) -> String) = { email, link, pin ->
HtmlDefaults.baseEmail("""
${
HtmlDefaults.logo?.let {
"""
""".trimIndent()
} ?: ""
}
Log In to ${generalSettings().projectName}
We received a request for a login email for ${email}. To log in, please click the link below or enter the PIN.
Click here to login
PIN: $pin
If you did not request to be logged in, you can simply ignore this email.
${generalSettings().projectName}
""".trimIndent())
},
) : ServerPathGroup(base.path) {
val pin = PinHandler(
cache,
"email",
availableCharacters = pinAvailableCharacters,
length = pinLength,
expiration = pinExpiration,
maxAttempts = pinMaxAttempts,
)
val loginEmail = path("login-email").post.typed(
summary = "Email Login Link",
description = "Sends a login email to the given address. The email will contain both a link to instantly log in and a PIN that can be entered to log in.",
errorCases = listOf(),
examples = listOf(
ApiExample(
input = "[email protected]",
output = Unit,
),
ApiExample(
input = "[email protected] ",
output = Unit,
name = "Casing doesn't matter",
notes = "The casing of the email address is ignored, and the input is trimmed."
),
),
successCode = HttpStatus.NoContent,
implementation = { user: Unit, addressUnsafe: String ->
val address = addressUnsafe.lowercase().trim()
val jwt = base.token(emailAccess.byEmail(address), base.jwtSigner().emailExpiration)
val pin = pin.establish(address)
val link = "${generalSettings().publicUrl}${base.landingRoute.path}?jwt=$jwt"
email().send(
Email(
subject = emailSubject(),
to = listOf(EmailLabeledValue(address)),
plainText = "Log in to ${generalSettings().projectName} as ${address}:\n$link\nPIN: $pin",
html = template(address, link, pin)
)
)
Unit
}
)
val loginEmailPin = path("login-email-pin").post.typed(
summary = "Email PIN Login",
description = "Logs in to the given account with a PIN that was provided in an email sent earlier. Note that the PIN expires in ${pinExpiration.toMinutes()} minutes, and you are only permitted ${pinMaxAttempts} attempts.",
errorCases = listOf(),
examples = listOf(ApiExample(input = EmailPinLogin("[email protected]", pin.generate()), output = "jwt.jwt.jwt")),
successCode = HttpStatus.OK,
implementation = { anon: Unit, input: EmailPinLogin ->
val email = input.email.lowercase().trim()
pin.assert(email, input.pin)
base.token(emailAccess.byEmail(email))
}
)
val oauthSettings = OauthProviderInfo.all.map {
it.settings.defineOptional("oauth_${it.identifierName}")
}
data class OauthEndpointSet(
val loginRedirect: HttpEndpoint,
val loginApi: ApiEndpoint0,
val callback: OauthCallbackEndpoint,
)
val oauthEndpointPairs by lazy {
OauthProviderInfo.all.zip(oauthSettings).mapNotNull {
val rawCreds = it.second() ?: return@mapNotNull null
@Suppress("UNCHECKED_CAST")
val credRead = (it.first.settings as OauthProviderInfo.SettingInfo).read
val callback = path("oauth/${it.first.pathName}/callback").oauthCallback(
oauthProviderInfo = it.first,
credentials = { credRead(rawCreds) }
) { response, uuid ->
val profile = it.first.getProfile(response)
val user = emailAccess.asExternal().byExternalService(profile)
val token = base.token(user, Duration.ofMinutes(1))
HttpResponse.redirectToGet("${generalSettings().publicUrl}${base.landingRoute.path}?jwt=$token")
}
val loginRedirect = path("oauth/${it.first.pathName}/login").get.handler {
HttpResponse.redirectToGet(callback.loginUrl(UUID.randomUUID()))
}
val loginApi = path("oauth/${it.first.pathName}/login-api").get.typed(
summary = "Log In via ${it.first.niceName}",
description = "Returns a URL which, when opened in a browser, will allow you to log into the system with ${it.first.niceName}.",
errorCases = listOf(),
examples = listOf(
ApiExample(
Unit,
"${it.first.loginUrl}?someparams=x"
)
),
implementation = { anon: Unit, _: Unit ->
callback.loginUrl(UUID.randomUUID())
}
)
OauthEndpointSet(
loginRedirect = loginRedirect,
loginApi = loginApi,
callback = callback
)
}
}
init {
Tasks.onSettingsReady {
oauthEndpointPairs
}
}
val loginEmailHtml = path("login-email/").get.handler {
HttpResponse(
body = HttpContent.Text(
string = HtmlDefaults.basePage(
"""
""".trimIndent()
),
type = ContentType.Text.Html
)
)
}
val loginEmailHtmlPost = path("login-email/form-post/").post.handler {
val email = it.body!!.text().split('&')
.associate { it.substringBefore('=') to URLDecoder.decode(it.substringAfter('='), Charsets.UTF_8) }
.get("email")!!.lowercase().trim()
val basis = try {
loginEmail.implementation(Unit, email)
} catch (e: Exception) {
e.printStackTrace()
throw e
}
HttpResponse(
body = HttpContent.Text(
string = HtmlDefaults.basePage(
"""
Success! An email has been sent with a code to log in.
""".trimIndent()
),
type = ContentType.Text.Html
)
)
}
val loginEmailPinHtmlPost = path("login-email/form-post-code/").post.handler {
val basis: String = try {
val content = it.body!!.text().split('&')
.associate { it.substringBefore('=') to URLDecoder.decode(it.substringAfter('='), Charsets.UTF_8) }
val pin = content.get("pin")!!.trim()
val email = content.get("email")!!.lowercase().trim()
loginEmailPin.implementation(Unit, EmailPinLogin(email, pin))
} catch (e: Exception) {
e.printStackTrace()
throw e
}
base.redirectToLanding(basis)
}
}