onsdag 30 september 2009

DDS: Programmera med personnummer

I en tidigare bloggpost så introducerade jag tillsammans med Dan Bergh Johnsson begreppet Domändriven säkerhet (DDS). Den här bloggposten utgår från den.


Så kommer då verkligheten. Ett riktigt system ska utvecklas och domändriven säkerhet ska praktiseras. Mitt senaste projekt var en nationell webbapplikation och web service inom svensk sjukvård.


Ett centralt begrepp i vår domänmodell var svenska personnummer. Systemen som anropar tjänsten anger patientens personnummer och personnumret är primärnyckel i databasen.


Därför tog vi en rad beslut för att vår kod ska hantera personnummer korrekt, allt enligt domändriven säkerhet.


  • Personnummer ska heta just 'personnummer' i koden. Detta trots att kodkommentarer, metod-, variabel- och klassnamn i övrigt är på engelska. 'Social security number' betyder något helt annat och 'Personal number' har ingen specifik betydelse. 'Personnummer' är en central del av domänmodellen och måste benämnas rätt i programkoden.
  • Det finns bara en klass som representerar ett personnummer i hela systemet. Den är väl dokumenterad och beskriver på engelska vad ett personnummer är.
  • Inkommande personnummer är förstås strängar men förutom en inledande koll i parsern att storleken är OK så valideras inte inkommande personnummer förrän ett riktigt personnummerobjekt skapas. Därför ska ett sånt objekt skapas så fort som möjligt. Skälet att vi kontrollerar storleken direkt i parsern är att vi vill motverka DoS-attacker som skickar in 1 Mb data i personnummerfältet.
  • Personnummer valideras i fallande komplexitetsordning enligt: längd (12 eller 13 tecken), tillåtna tecken (siffror och '-'), syntax (12 siffror eller 8 siffror '-' 4 siffror) samt semantik (kontrollsiffra enligt Luhnalgoritmen).


Allt väl så. Det var till och med roligt att implementera! Här kommer så min kollega Kalle Gustafssons och min implementation av svenska personnummer i Java. Notera camel case med särskrivning "PersonNummer" :).


package model;

import java.io.Serializable;
import java.util.Calendar;
import java.util.TimeZone;

import model.support.ChecksumSupport;
import support.log.Logger;
import support.log.SystemLogger;

/**
* A class representing a 12 digit Swedish personnummer (Swedish personal
* identification number). To avoid ambiguity the proper Swedish word is chosen
* instead of trying to define an English equivalent.
* <p>
* A personnummer is formatted in the following way:
* YYYYMMDD-BBBX where BBB is a three digit birth number (odd for men, even for women)
* and X is a check digit calculated from the birth date and birth number.
* <p>
* This class can also handle a Swedish samordningsnummer (co-ordination number)
* where the figure for the birthday is increased by the number 60 and then the
* check digit is calculated. For further reading see SKV 707 utg 2.</p>
* <p>
* Instances of this class are immutable value objects.</p>
* <p>
*
* @author Kalle Gustafsson, Omegapoint AB
* @author John Wilander, Omegapoint AB
*/
public class PersonNummer implements Serializable {
private static final long serialVersionUID = -160928058318117179L;

private static final Logger LOG = SystemLogger.getLogger(PersonNummer.class);

private static final int EARLIEST_YEAR = 1840;
private static final TimeZone STOCKHOLM_TIME_ZONE = TimeZone.getTimeZone("Europe/Stockholm");

private final long m_personNummer; // 12 digit no dash

/**
* Create a personnummer from a twelve digit code that may or may not have
* a dash between the date and the extension.
*
* @param code The personnummer
* @throws IllegalArgumentException On invalid input.
*/
protected PersonNummer(String code) throws InvalidPersonNummerException {
validate(code);
if (code.length() == 13) {
code = code.substring(0, 8) + code.substring(9);
}
m_personNummer = Long.parseLong(code);
}

/**
* This is declared package-private for the unit tests.
*/
static long validate(String code) throws InvalidPersonNummerException {
if (code.length() != 12 && (!(code.length() == 13 && code.charAt(8) == '-'))) {
throw new InvalidPersonNummerException("Must be 12 digits", code);
}

if (code.charAt(8) == '-') { // Remove dash if present
code = code.substring(0, 8) + code.substring(9, 13);
}

Calendar c = Calendar.getInstance(STOCKHOLM_TIME_ZONE);
c.clear();
c.setLenient(false);
try {
StringBuilder dateCode = new StringBuilder(code.substring(0, 8));
int coNumCheckInt = Integer.parseInt(dateCode.substring(6, 7));
if (coNumCheckInt > 3) { // Samordningsnummer (co-ordination number) -> date field
// incremented by 60. Read more in Skatteverket SKV 707, utg 2
dateCode.replace(6, 7, Integer.toString(coNumCheckInt - 6));
}

c.set(Integer.parseInt(dateCode.substring(0, 4)), Integer.parseInt(dateCode.substring(4, 6)) - 1, Integer.parseInt(dateCode.substring(6, 8)));

} catch (ArrayIndexOutOfBoundsException e) {
// Thrown by set if any value is out of range
throw new InvalidPersonNummerException(e.getMessage(), code);
} catch (NumberFormatException e) {
throw new InvalidPersonNummerException(e.getMessage(), code);
}

int year;
try {
year = c.get(Calendar.YEAR);
} catch (IllegalArgumentException e) {
// This has happened
throw new InvalidPersonNummerException(e.getMessage(), code);
}

if (year < EARLIEST_YEAR) {
throw new InvalidPersonNummerException("Year cannot be before " + EARLIEST_YEAR, code);
} else if (c.after(Calendar.getInstance(STOCKHOLM_TIME_ZONE))) {
throw new InvalidPersonNummerException("The date is in the future.", code);
}

int extension = 0;
try {
extension = Integer.parseInt(code.substring(8, 12));
} catch (NumberFormatException e) {
throw new InvalidPersonNummerException("The extension is not a number", code);
}
if (extension < 0) {
throw new InvalidPersonNummerException("The extension is less than 0", code);
} else if (extension > 9999) {
throw new InvalidPersonNummerException("The extension is greater than 9999", code);
}

long pid = Long.parseLong(code);
// Validate checksum. Don't include the leading two century digits
if (!ChecksumSupport.validateChecksum(pid % 10000000000L)) {
LOG.warn("Invalid checksum in personnummer: " + code + ". It should be " + ChecksumSupport.calculateChecksum(pid % 10000000000L));
}
return pid;
}

public String get12DigitWithDash() {
return (m_personNummer / 10000L) + "-" + String.format("%04d", m_personNummer % 10000L);
}

public String get12DigitNoDash() {
return "" + m_personNummer;
}

protected String get10DigitNoDash() {
return (get12DigitNoDash().substring(2));
}

protected String get10DigitWithDash() {
return (get12DigitWithDash().substring(2));
}

/**
* Get the personnummer as a 12-digit long.
*/
public long getCode() {
return m_personNummer;
}

@Override
public String toString() {
// Don't change this. Code that parses logs etc assumes that
// personnummers are formatted like this
return get12DigitWithDash();
}

@Override
public boolean equals(Object o) {
return (o != null) && (o instanceof PersonNummer) && (((PersonNummer) o).m_personNummer == m_personNummer);
}

@Override
public int hashCode() {
return (int) m_personNummer;
}
}

Och så supportklassen för beräkning och validering av checksummor enligt Luhnalgoritmen. Man vill gärna ha den som en utilityklass för att underlätta enhetstestning.

package model.support;

/**
* Utility class for validating personnummer-style checksums.
*
* @author Kalle Gustafsson, Omegapoint AB
*/
public final class ChecksumSupport {
/**
* Validate a personnummer style checksum.
* @param code The code to validate. The last digit of the code is the
* checksum.
* @return {@code true} if the checksum is valid.
*/
public static boolean validateChecksum(long code) {
int myChecksum = (int) (code % 10L);
int checksum = calculateChecksum(code / 10L);
return myChecksum == checksum;
}

/**
* Calculate the personnummer style checksum for the code.
*/
public static int calculateChecksum(long code) {
int cs = 0;
int multiple = 2;
while (code > 0) {
int pos = multiple * (int) (code % 10L);
cs += pos % 10 + pos / 10;
multiple = (multiple == 1 ? 2 : 1);
code = code / 10L;
}

// Subtract the sum modulo 10 from 10.
// The remainder becomes the checksum. If the remainder is 10 the checksum i 0.
return (10 - (cs % 10)) % 10;
}
}

Som sagt, allt väl så. Men det kom mera. Plötsligt fick vi ett felmeddelande i produktion. Någon hade angivit en patient med ett personnummer som slutade på 'Y'. Var det en bugg i anropande system? Eller kan personnummer ha bokstäver? Fortsättning följer, i en kommande bloggpost ...

[Uppdatering] Del 2 har utkommit och finns här.

1 kommentar:

Scott sa...

Bra skrivet, jag är mycket intresserad av uppföljningen! :)