Below is a complete, secure Keycloak custom SPI approach where:
✅ OTP is generated in Java (Keycloak)
✅ OTP is stored in AuthNotes (like SharedPreferences concept)
✅ Laravel is used only to send email
✅ Verify does NOT call Laravel (verification happens fully in Java)
Step 1: Decide where OTP lives (Keycloak AuthNotes)
We will store these values in Keycloak AuthenticationSessionModel:
NOTE_EMAIL_OTP_HASH → hash of OTP (secure)
NOTE_EMAIL_OTP_SENT_AT → time when OTP was generated
NOTE_EMAIL_OTP_TRIES → failed attempt counter
NOTE_EMAIL_OTP_SENT → flag (1/empty)
NOTE_EMAIL_VERIFIED → flag (1/empty)
NOTE_EMAIL_VALUE → email
Step 2: Update Laravel API to “send only” (no verify)
✅ Laravel should not generate OTP. It should accept OTP from Keycloak and email it.
public function keyclock_sendemailotp(Request $request)
{
Log::info('Request data keyclock_sendemailotp:', $request->all());
$gate = $this->utilityController->emailOtpSendGate($request, 5);
if (!$gate['ok']) return $gate['response'];
$request->validate([
'email' => 'required|email',
'otp' => 'required|digits:6',
]);
$getdata = User::where('email', $request->email)->first();
if ($getdata) {
return response()->json(['success' => false, 'message' => 'This email is already exist']);
}
$email = $request->email;
$otp = $request->otp;
$name = explode('@', $email)[0];
Mail::to($email)->send(new OtpMail($otp, $name, $email));
// ✅ Never return OTP
return response()->json(['success' => true, 'message' => 'OTP sent to your email']);
}
Step 3: Update your Java sender to send OTP to Laravel
LaravelEmailOtpSender.java
package in.motoshare.auth.phoneotp;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
public class LaravelEmailOtpSender implements EmailOtpSender {
private final String baseUrl;
public LaravelEmailOtpSender(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}
@Override
public void sendEmailOtp(String email) throws Exception {
throw new UnsupportedOperationException("Use sendEmailOtp(email, otp)");
}
public void sendEmailOtp(String email, String otp) throws Exception {
URL url = new URL(baseUrl + "/api/keyclock_sendemailotp");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
String safeEmail = email.replace("\"", "");
String payload = "{\"email\":\"" + safeEmail + "\",\"otp\":\"" + otp + "\"}";
try (OutputStream os = conn.getOutputStream()) {
os.write(payload.getBytes(StandardCharsets.UTF_8));
}
int status = conn.getResponseCode();
if (status != 200 && status != 201) {
throw new RuntimeException("Email OTP send failed. Status=" + status);
}
}
}
Step 4: Add secure helpers in your Authenticator (OTP + hashing)
Add these inside your authenticator class:
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.nio.charset.StandardCharsets;
private static final String NOTE_EMAIL_OTP_HASH = "EMAIL_OTP_HASH";
private static final String NOTE_EMAIL_OTP_SENT_AT = "EMAIL_OTP_SENT_AT";
private static final String NOTE_EMAIL_OTP_TRIES = "EMAIL_OTP_TRIES";
private static final int OTP_EXPIRY_SECONDS = 300; // 5 minutes
private static final int OTP_MAX_TRIES = 5;
private String generateOtp6() {
int n = 100000 + new SecureRandom().nextInt(900000);
return String.valueOf(n);
}
private String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private int safeInt(String v, int def) {
try { return Integer.parseInt(v); } catch (Exception ignored) { return def; }
}
private long safeLong(String v, long def) {
try { return Long.parseLong(v); } catch (Exception ignored) { return def; }
}
Step 5: SEND OTP branch (Generate in Java → Store in AuthNotes → Call Laravel only to email)
Replace your current "send" part with this secure version:
if ("send".equalsIgnoreCase(emailAction)) {
System.out.println("[RouteByHint] SEND branch");
if (email.isBlank() || !email.contains("@")) {
Response ch = render(context, countries, userType, false, false, email, "Please enter a valid email address.");
context.challenge(ch);
return;
}
// ✅ generate OTP in Keycloak
String otpPlain = generateOtp6();
String otpHash = sha256Hex(otpPlain);
// ✅ store securely in AuthNotes
s.setAuthNote(NOTE_EMAIL_OTP_HASH, otpHash);
s.setAuthNote(NOTE_EMAIL_OTP_SENT_AT, String.valueOf(System.currentTimeMillis()));
s.setAuthNote(NOTE_EMAIL_OTP_TRIES, "0");
try {
String baseUrl = getBaseUrl(context);
System.out.println("[RouteByHint] sending email otp via baseUrl=" + baseUrl);
LaravelEmailOtpSender sender = new LaravelEmailOtpSender(baseUrl);
// ✅ Laravel just sends email (no verify)
sender.sendEmailOtp(email, otpPlain);
System.out.println("[RouteByHint] SEND SUCCESS");
} catch (Exception e) {
System.out.println("[RouteByHint] SEND FAILED: " + e.getMessage());
e.printStackTrace();
// clean stored otp notes on failure
s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);
Response ch = render(context, countries, userType, false, false, email, "Unable to send Email OTP. Please try again.");
context.challenge(ch);
return;
}
// flags for UI
s.setAuthNote(NOTE_EMAIL_OTP_SENT, "1");
s.removeAuthNote(NOTE_EMAIL_VERIFIED);
s.setAuthNote(NOTE_EMAIL_VALUE, email);
Response ch = render(context, countries, userType, true, false, email, null);
context.challenge(ch);
return;
}
Step 6: VERIFY OTP branch (No Laravel call at all)
Add this "verify" branch in your action():
if ("verify".equalsIgnoreCase(emailAction)) {
System.out.println("[RouteByHint] VERIFY branch");
String storedHash = safe(s.getAuthNote(NOTE_EMAIL_OTP_HASH));
long sentAt = safeLong(safe(s.getAuthNote(NOTE_EMAIL_OTP_SENT_AT)), 0L);
int tries = safeInt(safe(s.getAuthNote(NOTE_EMAIL_OTP_TRIES)), 0);
if (storedHash.isBlank() || sentAt == 0L) {
Response ch = render(context, countries, userType, true, false, email, "Please send OTP first.");
context.challenge(ch);
return;
}
// expiry check
long now = System.currentTimeMillis();
long ageSeconds = (now - sentAt) / 1000L;
if (ageSeconds > OTP_EXPIRY_SECONDS) {
System.out.println("[RouteByHint] OTP expired");
s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);
s.removeAuthNote(NOTE_EMAIL_OTP_SENT);
Response ch = render(context, countries, userType, false, false, email, "OTP expired. Please send again.");
context.challenge(ch);
return;
}
// tries check
if (tries >= OTP_MAX_TRIES) {
System.out.println("[RouteByHint] Too many attempts");
Response ch = render(context, countries, userType, false, false, email, "Too many wrong attempts. Please send OTP again.");
context.challenge(ch);
return;
}
if (otp.isBlank() || otp.length() != 6) {
s.setAuthNote(NOTE_EMAIL_OTP_TRIES, String.valueOf(tries + 1));
Response ch = render(context, countries, userType, true, false, email, "Please enter a valid 6-digit OTP.");
context.challenge(ch);
return;
}
String enteredHash = sha256Hex(otp);
if (!enteredHash.equalsIgnoreCase(storedHash)) {
s.setAuthNote(NOTE_EMAIL_OTP_TRIES, String.valueOf(tries + 1));
Response ch = render(context, countries, userType, true, false, email, "Invalid OTP. Please try again.");
context.challenge(ch);
return;
}
// ✅ success
System.out.println("[RouteByHint] OTP verified successfully");
s.setAuthNote(NOTE_EMAIL_VERIFIED, "1");
// cleanup otp notes
s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);
Response ch = render(context, countries, userType, true, true, email, null);
context.challenge(ch);
return;
}
Step 7: RESET branch (your existing reset is OK)
Your reset branch is fine; just also clear our new notes:
if ("reset".equalsIgnoreCase(emailAction)) {
System.out.println("[RouteByHint] RESET branch");
s.removeAuthNote(NOTE_EMAIL_OTP_SENT);
s.removeAuthNote(NOTE_EMAIL_VERIFIED);
s.removeAuthNote(NOTE_EMAIL_VALUE);
s.removeAuthNote(NOTE_EMAIL_OTP_HASH);
s.removeAuthNote(NOTE_EMAIL_OTP_SENT_AT);
s.removeAuthNote(NOTE_EMAIL_OTP_TRIES);
Response ch = render(context, countries, userType, false, false, "", null);
context.challenge(ch);
return;
}
Step 8: What you achieved (final result)
✅ OTP generated in Keycloak
✅ Stored securely (hash + expiry + tries)
✅ Laravel used only to send email
✅ Verify is fully offline (no Laravel verify call)
Top comments (0)