For nylig har jeg set et tweet eller to fra folk der gerne vil kunne banke et NemID-login op på en hjemmeside i et Ruby on Rails-projekt. Derfor tænkte jeg at en lille guide til at få NemID-login op at køre i Ruby on Rails kunne være til nytte ude i verden.
Jeg vil holde mig til at få NemID til at fungere i JRuby. Skal man have det til at fungere i flere Ruby-implementationer, kræver det en del arbejde (mere herom nedenfor).
Jeg vil ikke forklare hvad NemID i bund og grund er for noget. Det har DanID gjort fint på deres egen side, https://www.nemid.nu/.
Der findes en såkaldt Tjenesteudbyderpakke med noget eksempel-kildekode til Java og .NET, så hvis man har mod på at forstå det, vil nedenstående måske virke trivielt.
Formatering
Tilsyneladende er Blogger et sindssygt dårligt sted at skrive kodeeksempler. Mange linjer bliver ombrudt så det bliver meningsforstyrrende, og HTML-tags i tekst er den meget forvirret over.
Undskyld.
Scope
NemID består faktisk af to løsninger: En til bankerne, og en til det offentlige. Vi vil fokusere på den offentlige løsning, da private firmaer kan få lov at gøre brug af denne løsning. Derfor er det sandsynligvis denne løsning du er interesseret i.
DanID har lavet nogle fine udkast til design af login-sider. I vores eksempel vil vi dog nøjes med en kedelig, hvid stil. Pointen med dette blog-indlæg er ikke at "style" en rigtig login-side, kun at få login til at fungere.
NemID giver mulighed både for login og signering af dokumenter. Vi holder os til login. Hvis du er interesseret i en tilsvarende guide til signering, så giv lyd i en kommentar nedenfor.
Desuden tilbyder DanID nogle services til, på baggrund af certifikatoplysningerne ifm. et login, at slå et tilhørende cpr-nummer op. Det kræver ekstra aftaler med DanID, og vi vil heller ikke komme ind på det.
Forberedelse
Før du kan følge skridtene her på siden til noget, er du nødt til at få dig en såkaldt tjenesteudbyder-aftale med DanID. Ud over at det giver mulighed for at benytte NemID på din egen hjemmeside, giver det dig adgang til DanID's testmiljøer. Og før du har adgang til disse testmiljøer, vil ikke engang test-projektet her på siden kunne køre hos dig, idet DanID bruger IP-blokering på testmiljøerne.
Når du indgår aftale med DanID, får du et certifikat registreret hos DanID. Dette certifikat skal bruges ifm. opsætning af applet'en, således at denne starter op med dit firmanavn.
DanID vil også fortælle dig hvordan du opretter testbrugere i testmiljøet.
OOAPI
OOAPI (Open Oces API) er et open source-library som findes både til Java og .NET. Det indeholder funktionalitet til at læse certifikater, lave gyldighedscheck på certifikater osv. Desuden kender dette library til de ekstra certifikat-felter som NemID benytter sig af.
Hvis man vil bruge NemID uden for JRuby eller IronRuby, er man nødt til at gen-implementere dele af OOAPI. Det er helt sikkert en hyggelig, fin opgave, men i dag holder vi os til blot at bruge OOAPI i Java-udgaven.
Selve OOAPI er blot en jar-fil, og den hentes fra DanID's hjemmeside for tjenesteudbydere. Denne side er dog ret svær at finde, så for nemheds skyld får du her et direkte link:
https://www.nets-danid.dk/produkter/for_tjenesteudbydere/nemid_tjenesteudbyder/nemid_tjenesteudbyder_support/tjenesteudbyderpakken/
Hent ooapi-1.81.2-with-dependencies.jar.
Tjenesteudbyder-eksemplet
Vi skal desuden, for at få vores legetøjseksempel til at fungere, have et keystore med et legetøjs-certifikat som i DanID's testmiljøer er registreret under tjenesteudbyderen "www.nemid.nu".
Hent derfor tuexample-source.zip fra ovenstående side og snup filen applet-parameter-signing-keystore-cvr30808460-uid1263281782319.jks.
Faktisk har du i tuexample-source.zip al information der skal til for at gennemføre alle scenarier med NemID: Login for privatpersoner, login for medarbejdere, signering osv. Og det er ikke alt for kompleks kode. Men det kan du altid vende tilbage til hvis du får behov for det.
Oprettelse af Rails-projektet
Først skal du sikre at du har installeret og bruger JRuby. Bruger du RVM, er det blot at skrive
rvm install jruby
rvm use jruby
fra kommandolinjen.
Lav en mappe hvori du har ooapi-1.81.2-with-dependencies.jar og applet-parameter-signing-keystore-cvr30808460-uid1263281782319.jks liggende. Kør dernæst følgende fra kommandolinjen:
export CLASSPATH=$JAVA_STUFF/:/$JAVA_STUFF/ooapi-1.81.2-with-dependencies.jar
(...hvor altså $JAVA_STUFF skal pege på den nævnte mappe med de to filer...)
Så lad os oprette et Rails-projekt:
rails new NemIdOnRails
cd NemIdOnRails
Environments
BouncyCastle skal sættes som Security Provider i JVM'en, så følgende skal tilføjes config/application.rb:
# Registrer BouncyCastle som Security Provider
java.security.Security.addProvider org.bouncycastle.jce.provider.BouncyCastleProvider.new
org.openoces.ooapi.environment.Environments.setEnvironments(org.openoces.ooapi.environment.Environments::Environment.value_of('OCESII_DANID_ENV_EXTERNALTEST'))
Desuden skal vi have styr på hvor det førnævnte keystore ligger, hvordan vi får fat i indholdet, og hvilken URL vi bruger for at tilgå vores testmiljø. Så sæt yderligere følgende ind i config/environments/development.rb:
ENV['key_store_url'] = 'applet-parameter-signing-keystore-cvr30808460-uid1263281782319.jks'
ENV['key_store_password'] = 'Test1234'
ENV['key_store_alias'] = 'alias'
ENV['key_password'] = 'Test1234'
ENV['server_url_prefix'] = 'https://syst2ext.danid.dk'
Når du kører i produktion, vil du bruge et andet keystore, og sikkert også nogle andre passwords... og server_url_prefix får du fra DanID.
Controllers, views, routes...
Vi vil lave en meget, meget simpel applikation med to controllers: Sessions og SecretPages. Sessions bruges til login, og SecretPages beskytter sin ene side, således at man skal være logget ind for at se den.
Vi beslutter at man er logget ind hvis den aktuelle session har en "pid"-attribut. PID er et felt i et personligt NemID-certifikat som unikt identificerer en person. Altså lidt som et cpr-nummer. Man kan derfor oplagt bruge PID'en som nøgle i en brugerdatabase.
Så lad os generere de to controllers:
rails g controller Sessions
rails g controller SecretPages
Og så skal vi lige opdatere config/routes.rb lidt:
resources :sessions
match 'hemmelig', :to => 'secret_pages#index'
Visning af applet'en
Vores Sessions-controllers new-action skal vise applet'en. Der er noget arbejde både i controlleren og i vores view.
For at undgå "replay attacks" finder vi på en tilfældig streng som brugeren via sit login signerer, og som vi så efterfølgende, når login er foretaget, checker i det svar som applet'en giver. Det er alt hvad vi gør i vores controller:
def new
session[:challenge] = SecureRandom.hex(10)
end
Selve view'et, app/views/sessions/new.html.erb, indeholder lidt kode, men er ikke vildt kompleks:
<%
signer = org.openoces.ooapi.web.Signer.new(ENV['key_store_url'], ENV['key_store_password'], ENV['key_store_alias'], ENV['key_password'])
generator = org.openoces.ooapi.web.OcesAppletElementGenerator.new signer
generator.server_url_prefix = ENV['server_url_prefix']
generator.challenge = session[:challenge]
generator.log_level = 'debug' # INFO/DEBUG/ERROR
%>
<h1>Log ind</h1>
<%=raw generator.generate_logon_applet_element sessions_path %>
Så det er ret nemt at se om brugeren rent faktisk ønskede at gennemføre et login, eller om vedkommende fortrød først. Men det er jo ikke nok at brugeren bare har trykket "OK" - vi forventer også et gyldigt certifikat, og oven i købet et personligt certifikat (i modsætning til fx et virksomhedscertifikat), og vi ønsker som tidligere nævnt at huske PID'en fra certifikatet:
def handle_ok
certificate_and_status = get_certificate_and_status
if certificate_and_status.certificate_status() != org.openoces.ooapi.certificate.CertificateStatus.value_of('VALID')
redirect_to :invalid_certificate
elsif !is_poces(certificate_and_status)
redirect_to :wrong_certificate_type
else
session[:pid] = certificate_and_status.certificate.pid
redirect_to '/hemmelig'
end
end
Den hemmelige side
Tjah, der er vel ikke så meget at sige om SecretPagesController. Den beskytter sin index-metode:
def index
redirect_to :controller => :sessions, :action => :new unless session[:pid]
end
I det tilhørende view (app/views/secret_pages/index.html.erb) vil vi gerne se vores PID, samt have mulighed for at logge ud igen:
<h1>Tillykke!</h1>
Det lykkedes dig at logge ind! Din PID er <%= session[:pid] %>.
<%= link_to 'Log ud igen', session_path(1), :method => :delete %>
Vores Sessions-controllers new-action skal vise applet'en. Der er noget arbejde både i controlleren og i vores view.
For at undgå "replay attacks" finder vi på en tilfældig streng som brugeren via sit login signerer, og som vi så efterfølgende, når login er foretaget, checker i det svar som applet'en giver. Det er alt hvad vi gør i vores controller:
def new
session[:challenge] = SecureRandom.hex(10)
end
Selve view'et, app/views/sessions/new.html.erb, indeholder lidt kode, men er ikke vildt kompleks:
<%
signer = org.openoces.ooapi.web.Signer.new(ENV['key_store_url'], ENV['key_store_password'], ENV['key_store_alias'], ENV['key_password'])
generator = org.openoces.ooapi.web.OcesAppletElementGenerator.new signer
generator.server_url_prefix = ENV['server_url_prefix']
generator.challenge = session[:challenge]
generator.log_level = 'debug' # INFO/DEBUG/ERROR
%>
<h1>Log ind</h1>
<%=raw generator.generate_logon_applet_element sessions_path %>
Her bruger vi så alle de værdier vi har sat i development.rb tidligere. Desuden bruger vi OOAPI til at generere selve applet-tag'et til siden. I den sidste linje beder vi desuden applet'en om at POST'e tilbage til "sessions_path".
Prøv det! Kør "rails s" fra kommandolinjen, og gå ind på http://localhost:3000/sessions/new.
Check af svaret
Som sagt har vi bedt applet'en om at POST'e sit svar ind på "sessions_path", dvs. vi rammer vores create-metode i SessionsController.rb. Der skal heldigvis ikke så meget til at checke resultatet fra applet'en... lad os starte med følgende:
def create
result = org.openoces.securitypackage.SignHandler.base64Decode(request[:result])
if result == 'ok'
handle_ok
elsif result == 'cancel'
redirect_to :cancelled
else
redirect_to :unknown_action
end
end
Så det er ret nemt at se om brugeren rent faktisk ønskede at gennemføre et login, eller om vedkommende fortrød først. Men det er jo ikke nok at brugeren bare har trykket "OK" - vi forventer også et gyldigt certifikat, og oven i købet et personligt certifikat (i modsætning til fx et virksomhedscertifikat), og vi ønsker som tidligere nævnt at huske PID'en fra certifikatet:
def handle_ok
certificate_and_status = get_certificate_and_status
if certificate_and_status.certificate_status() != org.openoces.ooapi.certificate.CertificateStatus.value_of('VALID')
redirect_to :invalid_certificate
elsif !is_poces(certificate_and_status)
redirect_to :wrong_certificate_type
else
session[:pid] = certificate_and_status.certificate.pid
redirect_to '/hemmelig'
end
end
Også her er der et par løse ender, nemlig hvordan vi haler certifikatet og status ud af request'et, og hvordan vi checker at der er tale om et personligt certifikat. Først certifikatet og status:
def get_certificate_and_status
signature, challenge, service_provider = request[:signature], session[:challenge], 'www.nemid.nu'
org.openoces.securitypackage.LogonHandler.validateAndExtractCertificateAndStatus(org.openoces.securitypackage.SignHandler.base64Decode(signature), challenge, service_provider)
end
Så det hele ordnes af OOAPI. Bemærk brugen af 'www.nemid.nu'. Her vil du, når aftalen med DanID er på plads, indsætte det navn som DanID har registreret dig under. Desuden giver vi vores tilfældige streng fra tidligere med, så OOAPI checker at brugeren har signeret dette.
Det er nemt at checke at vi har fået fat i et personligt certifikat:
def is_poces(certificate_and_status)
certificate_and_status.certificate.kind_of? org.openoces.ooapi.certificate.PocesCertificate
end
Men en lille ting mere... Det er jo applet'en der kalder tilbage til vores controller, og det sker jo så uden Rails' indbyggede CSRF-beskyttelse. Så ovenstående kode vil bare give en fejl når brugeren logger ind. Men da vi har vores tilfældige streng og checker denne, er vi sikre på at alt er sikkert alligevel. Så lad os slå CSRF-beskyttelse fra på vores create-metode ved at skrive følgende øverst i vores SessionsController:
protect_from_forgery :except => :create
Åh ja... og vi vil jo også gerne kunne logge ud igen, dvs. nedlægge vores session:
def destroy
session[:pid] = nil
redirect_to :action => :new
end
I ovenstående kode har vi et par ekstra sider vi redirigerer til når noget er gået galt. Det efterlades som en opgave til læseren at implementere dette :-)
Den hemmelige side
Tjah, der er vel ikke så meget at sige om SecretPagesController. Den beskytter sin index-metode:
def index
redirect_to :controller => :sessions, :action => :new unless session[:pid]
end
I det tilhørende view (app/views/secret_pages/index.html.erb) vil vi gerne se vores PID, samt have mulighed for at logge ud igen:
<h1>Tillykke!</h1>
Det lykkedes dig at logge ind! Din PID er <%= session[:pid] %>.
<%= link_to 'Log ud igen', session_path(1), :method => :delete %>
Det var så det
Mere er der ikke i det. Men som sagt, for overhovedet at kunne starte på denne lille trin-for-trin-guide skal du have en aftale i stand med DanID først.
Som det sig hør og bør, så har jeg lavet et projekt på GitHub med al koden, så du kan se om jeg har snydt: https://github.com/olefriis/NemID-Rails-login
Held og lykke!