Waarom wij gekozen hebben om SSO portal te bouwen in AWS? Het antwoord is simpel: we wilden zo min mogelijk zelf bouwen. Daarom viel de keuze op AWS Cognito. Hoe we die keuze precies gemaakt hebben en welk probleem we hebben opgelost met het bouwen van dit SSO portal vertel ik je in dit blog.

Een beetje context is wel handig, dus we beginnen bij het begin. Een bestaande applicatie hebben wij decoupled in losse apps. Hierdoor konden we oude oplossingen uitfaseren, waardoor deze onderdelen makkelijker te beheren, uit te bouwen en te testen waren. Voor deze losse applicaties moest wel de authenticatie opnieuw geïmplementeerd worden en dat wilden we maar één keer doen. Zie daar de noodzaak voor een SSO portaal.

Wij zijn eerst gaan onderzoeken wat de mogelijkheden waren. Daarbij hebben we gekeken naar hoeveel effort het kost om een oplossing te bouwen, hoe onderhoudbaar en schaalbaar de oplossing is en hoe makkelijk de oplossing uit te breiden is op basis van requirements. De verschillende opties hebben we naast elkaar gelegd en toen kwamen we bij AWS Cognito uit.

Wat zijn dan zoal de voordelen van Cognito?

Er is één centrale plek om je userbase (user pool in AWS) bij te houden en er zijn veel functionaliteiten out of the box, die je hoef je dus niet allemaal zelf te bouwen.

Denk bijvoorbeeld aan:
•  Registratie (en activatie)
•  MFA
•  Identity providers
•  Wachtwoord vergeten functionaliteit.

Er zijn daarbij verschillende manieren om gebruik te maken van AWS Cognito. Je hebt bijvoorbeeld de Hosted UI, waarmee je snel een app opzet en kunt koppelen aan je eigen (web)app. Daarbij kun je gebruik maken van AWS Amplify, wat een development platform is voor het bouwen van veilige, schaalbare mobiele applicaties en webapplicaties.

Een andere optie is om gebruik te maken van de API die beschikbaar is, een oplossing waar wij voor gekozen hebben. Met de hosted UI kun je namelijk wel snel van start en heb je best wat mogelijkheden qua customisations, maar loop je uiteindelijk toch tegen limitaties aan. Daarom hebben wij besloten om gebruik te maken van de API, zodat we de flexibiliteit hebben om het proces naar onze hand te zetten.

Voor beide oplossingen is wat te zeggen. Het hangt per situatie af wat de beste keuze is hierin. Kijk daarom vooral naar wat je nodig hebt en welk probleem je wilt oplossen.

Eerst denken, dan doen

Voordat we één en ander konden gaan bouwen moest er eerst goed nagedacht worden over een aantal zaken. We hadden namelijk te maken met een bestaande userbase. In het kader van gebruikersvriendelijkheid was het daarom erg belangrijk dat gebruikers gewoon konden blijven inloggen met hun reeds bestaande credentials. Er moest dus een migratieproces komen om de bestaande userbase in de Cognito user pool te krijgen. En er moest een oplossing komen om gebruikers te kunnen laten inloggen met hun bestaande wachtwoord. Om goed in kaart te brengen hoe deze flow voor gebruikers zou lopen, hebben we dit allemaal even uitgewerkt in een flowchart.

Opzetten van het migratieproces

Het migratieproces was vrij makkelijk op te zetten door gebruik te maken van de API. Wij hebben een migratiescript geschreven, waarbij we o.a. naam, emailadres en het bestaande wachtwoord gemigreerd hebben. Met een zogenaamde 'pre authentication Lambda trigger' kunnen wij inhaken op het authenticatieproces. Hiermee passen we logica toe om ervoor te zorgen dat men de eerste keer kan inloggen met het oude wachtwoord, waarna we het wachtwoord opnieuw opslaan in Cognito. Dit alles hebben we eerst gevalideerd door een 'Proof of Concept' te bouwen. Want het is wel zo fijn om zeker te weten dat een goed idee op papier ook goed werkt in de praktijk.

AWS Lambda Template JSON

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters" : {
    "ExistingSecurityGroups" : {
      "Type" : "List<AWS::EC2::SecurityGroup::Id>"
    },
    "ExistingVPC" : {
      "Type" : "AWS::EC2::VPC::Id",
      "Description" : "The VPC ID that includes the security groups in the ExistingSecurityGroups parameter."
    },
    "InstanceType" : {
      "Type" : "String",
      "Default" : "t2.micro",
      "AllowedValues" : ["t2.micro", "m1.small"]
    }
  },
  "Mappings": {
    "AWSInstanceType2Arch" : {
      "t2.micro"    : { "Arch" : "HVM64"  },
      "m1.small"    : { "Arch" : "HVM64"   }
    },
    
    "AWSRegionArch2AMI" : {
      "us-east-1"        : {"HVM64" : "ami-0ff8a91507f77f867", "HVMG2" : "ami-0a584ac55a7631c0c"},
      "us-west-2"        : {"HVM64" : "ami-a0cfeed8", "HVMG2" : "ami-0e09505bc235aa82d"},
      "us-west-1"        : {"HVM64" : "ami-0bdb828fd58c52235", "HVMG2" : "ami-066ee5fd4a9ef77f1"},
      "eu-west-1"        : {"HVM64" : "ami-047bb4163c506cd98", "HVMG2" : "ami-0a7c483d527806435"},
      "eu-central-1"     : {"HVM64" : "ami-0233214e13e500f77", "HVMG2" : "ami-06223d46a6d0661c7"},
      "ap-northeast-1"   : {"HVM64" : "ami-06cd52961ce9f0d85", "HVMG2" : "ami-053cdd503598e4a9d"},
      "ap-southeast-1"   : {"HVM64" : "ami-08569b978cc4dfa10", "HVMG2" : "ami-0be9df32ae9f92309"},
      "ap-southeast-2"   : {"HVM64" : "ami-09b42976632b27e9b", "HVMG2" : "ami-0a9ce9fecc3d1daf8"},
      "sa-east-1"        : {"HVM64" : "ami-07b14488da8ea02a0", "HVMG2" : "NOT_SUPPORTED"},
      "cn-north-1"       : {"HVM64" : "ami-0a4eaf6c4454eda75", "HVMG2" : "NOT_SUPPORTED"}
    }
    
  },
  "Resources" : {
    "SecurityGroup" : {
      "Type" : "AWS::EC2::SecurityGroup",
      "Properties" : {
        "GroupDescription" : "Allow HTTP traffic to the host",
        "VpcId" : {"Ref" : "ExistingVPC"},
        "SecurityGroupIngress" : [{
          "IpProtocol" : "tcp",
          "FromPort" : "80",
          "ToPort" : "80",
          "CidrIp" : "0.0.0.0/0"
        }],
        "SecurityGroupEgress" : [{
          "IpProtocol" : "tcp",
          "FromPort" : "80",
          "ToPort" : "80",
          "CidrIp" : "0.0.0.0/0"
        }]
      }
    },
    "AllSecurityGroups": {
      "Type": "Custom::Split",
      "Properties": {
        "ServiceToken": { "Fn::GetAtt" : ["AppendItemToListFunction", "Arn"] },
        "List": { "Ref" : "ExistingSecurityGroups" },
        "AppendedItem": { "Ref" : "SecurityGroup" }
      }
    },
    "AppendItemToListFunction": {
      "Type": "AWS::Lambda::Function",
      "Properties": {
        "Handler": "index.handler",
        "Role": { "Fn::GetAtt" : ["LambdaExecutionRole", "Arn"] },
        "Code": {
          "ZipFile":  { "Fn::Join": ["", [
            "var response = require('cfn-response');",
            "exports.handler = function(event, context) {",
            "   var responseData = {Value: event.ResourceProperties.List};",
            "   responseData.Value.push(event.ResourceProperties.AppendedItem);",
            "   response.send(event, context, response.SUCCESS, responseData);",
            "};"
          ]]}
        },
        "Runtime": "nodejs8.10"
      }
    },
    "MyEC2Instance" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "ImageId": { "Fn::FindInMap": [ "AWSRegionArch2AMI", { "Ref": "AWS::Region" }, { "Fn::FindInMap": [
          "AWSInstanceType2Arch", { "Ref": "InstanceType" }, "Arch" ] } ]
        },
        "SecurityGroupIds" : { "Fn::GetAtt": [ "AllSecurityGroups", "Value" ] },
        "InstanceType" : { "Ref" : "InstanceType" }
      }
    },
    "LambdaExecutionRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [{ "Effect": "Allow", "Principal": {"Service": ["lambda.amazonaws.com"]}, "Action": ["sts:AssumeRole"] }]
        },
        "Path": "/",
        "Policies": [{
          "PolicyName": "root",
          "PolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [{ "Effect": "Allow", "Action": ["logs:*"], "Resource": "arn:aws:logs:*:*:*" }]
          }
        }]
      }
    }
  },
  "Outputs" : {
    "AllSecurityGroups" : {
      "Description" : "Security Groups that are associated with the EC2 instance",
      "Value" : { "Fn::Join" : [ ", ", { "Fn::GetAtt": [ "AllSecurityGroups", "Value" ] }]}
    }
  }
}

De implementatie

Na het migratieproces is het tijd voor de daadwerkelijke implementatie. Je start met het weggooien van alles wat je in de console van AWS bij elkaar geklikt hebt. Waarom? Dat lees je in dit blog: Infrastructure as code.

Je begint dus met het uitzoeken hoe CloudFormation (of een alternatief) werkt en daarmee zet je je hele infrastructuur op, waarbij dus alles vastgelegd ligt in code: Infrastructure as code. Het aanmaken van de diverse user pools in Cognito (voor alle stages in de DTAP straat hebben we een aparte user pool), het configureren van de user pool (password policies, required fields, etc.) gebeurt allemaal via CloudFormation.

Daarbij loop je al snel tegen permissies aan en deze dienen ingesteld te worden via IAM (Identity and Access Management), waarmee je alle permissies kunt instellen die je maar kunt verzinnen. Uiteraard ook weer via CloudFormation. En de lambda functie waar we eerder over gesproken hebben? Je raadt het al, configureren via CloudFormation.

De CloudFormation configuratie passen wij overigens toe via de AWS CLI [1], waarbij we de commando’s die uitgevoerd moeten worden in een shell script hebben verwerkt. Hierdoor liggen de commando's vast in code, kunnen aanpassingen hierop gereviewed worden én kan je controlestappen inbouwen door bijvoorbeeld een ‘dry run’ uit te voeren. Hiermee controleer je wat er aangepast zou worden indien je de configuratie daadwerkelijk uitvoert. Dit kun je vanaf je lokale werkomgeving uitvoeren, maar dit kan ook vanuit bijvoorbeeld Bitbucket pipelines of Gitlab.

Zodra deze zaken geconfigureerd zijn kan de authenticatie en de wachtwoord-vergeten-functionaliteit gebouwd worden, waarbij je gebruikt maakt van de API van Cognito. Voor de wachtwoord-vergeten-functionaliteit worden ook e-mails verstuurd met een verificatie code, hiervoor wordt AWS SES geadviseerd. Deze service kun je vrijwel direct gebruiken, maar je dient wel het afzenderadres of het gehele domein te valideren om daadwerkelijk e-mails te kunnen gaan versturen. Qua configuratie hoef je vrij weinig in te stellen, maar de zaken die je moet uitvoeren kun je het beste via de CLI tool [1] instellen en niet via de console, waarbij het wederom belangrijk is om de CLI commands vast te leggen.

Aandachtspunten

  • Zorg bij het configureren van AWS Cognito ervoor dat de optie 'Case insensitivity' voor username en/of het e-mailadres ingeschakeld is. Dit wordt ook door AWS zelf geadviseerd.

  • Bepaal bij het inrichten van je user pool wat applicatie specifieke attributen van een gebruiker zijn en welke attributen algemeen zijn. De applicatie specifieke attributen horen niet in Cognito thuis, dus het is belangrijk hier goed over na te denken en een duidelijke scheiding in aan te brengen.

  • Hou rekening met de ingestelde Rate limits van de services die je gebruikt. Als je denkt dat het nodig is om de Rate limits op te hogen, vraag je dan af of jouw probleem niet op andere wijze op te lossen valt. Of dat je misschien iets niet goed doet? De limits in AWS zijn over het algemeen goed ingesteld en voldoen vaak ook prima, maar voor specifieke gevallen kunnen deze opgehoogd worden door een verzoek in te dienen bij AWS support.

  • Als je gebruik maakt van AWS SES én je maakt gebruik van SPF records voor het domein van waaruit je de mailtjes verstuurd, dan dien je er wel voor te zorgen dat de DNS bijgewerkt wordt [2].

  • De productieomgeving van SES dient uit de sandbox modus gehaald te worden. Dit kun je doen door een support ticket te openen [3] en deze is in ons geval binnen een paar uur opgepakt.

Bronnen

[1] https://docs.aws.amazon.com/cli/index.html
[2] https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-spf.html
[3] https://docs.aws.amazon.com/ses/latest/DeveloperGuide/request-production-access.html


Heel veel succes! Heb je vragen of kom je er niet helemaal uit? Mail me gerust, ik help je graag!


Geschreven door: Arjan Passchier

Meer kennis bijspijkeren? Kom dan naar onze Meetup: Ode aan de Code!

Bekijk onze Meetups

Wij zijn altijd op zoek naar getalenteerde vakgenoten!

Bekijk onze vacatures